SharePoint 2013 features a new set of links called the Suite Bar Links that are displayed in the top right corner of every SharePoint page. By default these links include “Newsfeed”, “SkyDrive”, and “Sites”.
When first seeing this links my first thought was “how do I change them?”. In exploring the master page, we can see that the links are added to the page with this delegate control:
<SharePoint:DelegateControl id="ID_SuiteLinksDelegate" ControlId="SuiteLinksDelegate" runat="server" />
This presents two options for changing the links:
1. Remove the delegate control (or hide it) and simply hard code the links in the master page. This is only a viable option if you using a single language site and you are only making the changes for a single site collection. If you were using a multilingual site, you would lose the automatically translated links that SharePoint provides. And you would need to make this change in the master pages of every site collection.
2. Override the delegate control with a custom control. This presents a unique challenge in that all of the default links are hard-coded in non-public methods of the SharePoint assemblies.
Creating a Custom Delegate Control to Override SuiteLinksDelegate
The goal with this custom control is to maintain the same functionality as the built-in SuiteLinksDelegate control while adding our own links. To do this, we are going to use similar code to what the built-in control uses, some .NET reflection, and some techniques of my own.
To get started, ensure that you have SharePoint 2013 installed and configured along with Visual Studio 2012 installed with the SharePoint 2013 developer tools.
-
Launch Visual Studio 2012 and create a new project: Templates > Visual C# > Office/SharePoint > SharePoint Solutions > SharePoint 2013 Project. For this example, I named the project “SharePointDelegates”. When prompted, select Farm Solution as the solution type.
-
Add the following references to your project (you will need to browse to C:Program FilesCommon FilesMicrosoft SharedWeb Server Extensions15ISAPI): Microsoft.Office.Server.dll, Microsoft.Office.Server.Search.dll, Microsoft.Office.Server.UserProfiles.dll, and Microsoft.SharePoint.Portal.dll.
-
Add a “UrlUtility” class (right-click the solution name > Add > Class.
-
The UrlUtility class is used by the default SuiteLinksDelegate control and rather than reinvent the wheel, we are just copying the class from the SharePoint assemblies:
using Microsoft.SharePoint; using Microsoft.SharePoint.Administration; using Microsoft.SharePoint.Upgrade; using Microsoft.SharePoint.Utilities; using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using System.Text; using System.Threading.Tasks; namespace SharePointDelegates { ///
/// Methods borrowed from the existing SuiteLinks Delegate Control /// internal class UrlUtility : SPUrlUtility { private UrlUtility() { } internal static string ConvertToLegalFileName(string inputName, char replacementChar) { int length = inputName.Length; StringBuilder builder = new StringBuilder(inputName); bool flag = false; for (int i = length - 1; i >= 0; i--) { char character = inputName[i]; bool flag2 = character == '.'; if (!SPUrlUtility.IsLegalCharInUrl(character) || (flag2 && (((i == 0) || (i == (length - 1))) || flag))) { builder[i] = replacementChar; } flag = flag2; } return builder.ToString(); } internal static string EnsureNoTrailingSlash(string strUrl) { char[] trimChars = new char[] { '/' }; return strUrl.TrimEnd(trimChars); } internal static string EnsureTrailingSlash(string strUrl) { if (!strUrl.EndsWith("/", StringComparison.OrdinalIgnoreCase)) { strUrl = strUrl + "/"; } return strUrl; } public static bool EquivalentUris(Uri uri1, Uri uri2) { uri1 = SPFarm.Local.AlternateUrlCollections.RebaseUriWithAlternateUri(uri1, 0); uri2 = SPFarm.Local.AlternateUrlCollections.RebaseUriWithAlternateUri(uri2, 0); return uri1.Equals(uri2); } public static string GetImageUrl(string imageName) { return ("/" + SPUtility.ContextLayoutsFolder + "/images/" + imageName); } internal static string GetUniqueObjectUrl(SPSite site, string desiredServerRelativeUrl, string fileExtension, int maxRetry) { string uniqueUrl = null; if (!SPManager.PeekIsUpgradeRunning()) { Guid siteId = site.ID; SPSecurity.RunWithElevatedPrivileges(delegate { using (SPSite spSite = new SPSite(siteId)) { uniqueUrl = GetUniqueObjectUrlHelper(spSite, desiredServerRelativeUrl, fileExtension, maxRetry); } }); } else { uniqueUrl = GetUniqueObjectUrlHelper(site, desiredServerRelativeUrl, fileExtension, maxRetry); } return uniqueUrl; } private static string GetUniqueObjectUrlHelper(SPSite site, string desiredServerRelativeUrl, string fileExtension, int maxRetry) { if (desiredServerRelativeUrl == null) { throw new ArgumentNullException("desiredServerRelativeUrl"); } if (site == null) { throw new ArgumentNullException("site"); } desiredServerRelativeUrl = desiredServerRelativeUrl.Trim(); if (desiredServerRelativeUrl.EndsWith("/", StringComparison.OrdinalIgnoreCase)) { throw new ArgumentException(); } int num = 0; string str = desiredServerRelativeUrl; bool flag = false; if (!string.IsNullOrEmpty(fileExtension)) { flag = true; if (str.EndsWith(fileExtension, StringComparison.OrdinalIgnoreCase)) { str = str.Substring(0, str.Length - fileExtension.Length); } desiredServerRelativeUrl = str + fileExtension; } do { try { SPWeb web = site.OpenWeb(); try { if (web.GetObject(desiredServerRelativeUrl) != null) { desiredServerRelativeUrl = str + num.ToString(CultureInfo.InvariantCulture); if (flag) { desiredServerRelativeUrl = desiredServerRelativeUrl + fileExtension; } num++; } else { return desiredServerRelativeUrl; } } finally { if (web != null) { web.Close(); } } } catch (FileNotFoundException) { return desiredServerRelativeUrl; } } while ((maxRetry < 0) || (num <= maxRetry)); return null; } internal static string SafeAppendQueryStringParameter(string strUrl, string strKey, string strValue) { if (strUrl.IndexOf(strKey + "=", StringComparison.OrdinalIgnoreCase) < 0) { string str = SPHttpUtility.UrlKeyValueEncode(strKey, strValue); if (strUrl.IndexOf("?") > 0) { strUrl = strUrl.Trim() + "&" + str; return strUrl; } strUrl = strUrl.Trim() + "?" + str; } return strUrl; } } } -
Add a “SuiteLinksHelper” class.
-
The SuiteLinksHelper class contains methods that mimic the internal SharePoint methods used by the SuiteLinksDelegate control. In places that accessed other internal SharePoint methods, .NET reflection is used instead to access the same methods.
using Microsoft.Office.Server.UserProfiles; using Microsoft.Office.Server.Administration; using Microsoft.SharePoint; using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; using System.Web; using System.Reflection; using Microsoft.SharePoint.Utilities; using Microsoft.SharePoint.WebControls; using Microsoft.SharePoint.Administration; using Microsoft.SharePoint.Portal.WebControls; namespace SharePointDelegates { ///
/// Methods borrowed from the existing SuiteLinks Delegate Control /// Portions of the code that accessed internal SharePoint methods were replaced with reflection /// class SuiteLinksHelper { // Sets an item in the cache of the current context internal static void SetItem(string key, string value) { if (HttpContext.Current != null) { HttpContext.Current.Items[key] = value; } } // Gets an item from the cache of the current context internal static string GetItem(string key) { if (HttpContext.Current == null) { return null; } return (HttpContext.Current.Items[key] as string); } // Gets the current SharePoint Url Zone (alternate access map) internal static SPUrlZone CurrentUrlZone { get { if (HttpContext.Current != null) { try { return SPControl.GetContextSite(HttpContext.Current).Zone; } catch { } } return SPUrlZone.Default; } } // Sets the needed Urls for the default SuiteLinksDelegate control in the cache of the current context internal static void EnsureProfileUrlsCached() { var upaProxyType = Type.GetType("Microsoft.Office.Server.Administration.UserProfileApplicationProxy, Microsoft.Office.Server.UserProfiles, Version=15.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c"); var userProfileManager = new UserProfileManager(SPServiceContext.Current, false, false); var prop = userProfileManager.GetType().GetProperty("UserProfileApplicationProxy", AllBindings); var proxy = prop.GetValue(userProfileManager, null); if (proxy != null) { var rawPartitionIDProperty = upaProxyType.GetMethod("GetRawPartitionID", AllBindings, null, new Type[] { typeof(SPServiceContext) }, null); object[] rawPartitionIDParameters = { SPServiceContext.Current }; var rawPartitionID = rawPartitionIDProperty.Invoke(proxy, rawPartitionIDParameters) as Guid?; var mySitePortalUrlProperty = upaProxyType.GetMethod("GetMySitePortalUrl", AllBindings, null, new Type[] { typeof(SPUrlZone), typeof(Guid) }, null); object[] mySitePortalUrlParameters = { CurrentUrlZone, rawPartitionID }; var mySitePortalUrl = mySitePortalUrlProperty.Invoke(proxy, mySitePortalUrlParameters) as string; if (!string.IsNullOrEmpty(mySitePortalUrl)) { if (string.IsNullOrEmpty(GetItem("SocialData$MySiteHostURL"))) { string str2 = UrlUtility.EnsureTrailingSlash(mySitePortalUrl); SetItem("SocialData$MySiteHostURL", str2); } if (string.IsNullOrEmpty(GetItem("SocialData$ProfileURL"))) { string userProfileURL = GetUserProfileURL(mySitePortalUrl, string.Empty, string.Empty); SetItem("SocialData$ProfileURL", userProfileURL); } if (string.IsNullOrEmpty(GetItem("SocialData$MyProfileSettingsURL"))) { string str4 = GetEditProfileUrl(SPServiceContext.Current, mySitePortalUrl, "", ""); SetItem("SocialData$MyProfileSettingsURL", str4); } if ((SPContext.Current != null) && (SPContext.Current.Web != null)) { if (string.IsNullOrEmpty(GetItem("SocialData$MyAlertsURL"))) { string str5 = SPUrlUtility.CombineUrl(SPContext.Current.Web.ServerRelativeUrl, SPUtility.ContextLayoutsFolder + "/mysubs.aspx"); SetItem("SocialData$MyAlertsURL", str5); } if (string.IsNullOrEmpty(GetItem("SocialData$MyLanguageAndRegionURL"))) { string strUrl = SPUrlUtility.CombineUrl(SPContext.Current.Web.ServerRelativeUrl, SPUtility.ContextLayoutsFolder + "/regionalsetng.aspx?type=user"); string strValue = SPHttpUtility.HtmlEncode(DeltaPage.RemoveDeltaQueryParameters(SPContext.Current.Site.MakeFullUrl(HttpContext.Current.Request.RawUrl.ToString()))); strUrl = UrlUtility.SafeAppendQueryStringParameter(strUrl, "source", strValue); SetItem("SocialData$MyLanguageAndRegionURL", strUrl); } } } } } // Returns the Url to the My Site host from the cache of the current context internal static string MySiteHostURL { get { return GetItem("SocialData$MySiteHostURL"); } } // Gets the localized string from the SharePoint string resources internal static string GetString(LocStringId lsid) { var stringResourceManagerType = Type.GetType("Microsoft.SharePoint.Portal.WebControls.StringResourceManager, Microsoft.Office.Server.Search, Version=15.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c"); var getStringProperty = stringResourceManagerType.GetMethod("GetString", AllBindings); object[] parameters = { lsid }; return getStringProperty.Invoke(null, parameters) as string; } // Gets the Url to the current user's profile internal static string GetUserProfileURL(string profileWebUrl, string strIdentifier, string strValue) { if (string.IsNullOrEmpty(profileWebUrl)) { return null; } return (UrlUtility.EnsureTrailingSlash(profileWebUrl) + "Person.aspx" + strIdentifier + (strIdentifier.Equals("?accountname=", StringComparison.OrdinalIgnoreCase) ? SPHttpUtility.UrlKeyValueEncode(strValue).Replace(":", "%3A") : SPHttpUtility.UrlKeyValueEncode(strValue))); } // Gets the Url to the current user's profile edit page internal static string GetEditProfileUrl(SPServiceContext serviceContext, string profileWebUrl, string sourceUrl = "", string section = "") { if (serviceContext == null) { return string.Empty; } SPUserSettingsProvider usp = null; if ((SPContext.Current != null) && (SPContext.Current.Site != null)) { SPWebApplication webApplication = SPContext.Current.Site.WebApplication; if (webApplication != null) { usp = webApplication.UserSettingsProvider; } if (string.IsNullOrEmpty(sourceUrl)) { sourceUrl = SPContext.Current.Site.MakeFullUrl(HttpContext.Current.Request.RawUrl.ToString()); sourceUrl = SPHttpUtility.HtmlEncode(DeltaPage.RemoveDeltaQueryParameters(sourceUrl)); } } return GetEditProfileUrl(GetMySitePortalLayoutsUrl(profileWebUrl, "EditProfile.aspx"), usp, sourceUrl, section); } private static string GetEditProfileUrl(string baseUrl, SPUserSettingsProvider usp, string sourceUrl, string section) { if (!string.IsNullOrEmpty(section)) { baseUrl = UrlUtility.SafeAppendQueryStringParameter(baseUrl, "Section", section); } if (usp != null) { baseUrl = UrlUtility.SafeAppendQueryStringParameter(baseUrl, "UserSettingsProvider", usp.ProviderIdentifier.ToString()); if (!string.IsNullOrEmpty(sourceUrl)) { baseUrl = UrlUtility.SafeAppendQueryStringParameter(baseUrl, "ReturnUrl", sourceUrl); } } return baseUrl; } // Gets the Url of the specified relative path in the My Site _layouts directory internal static string GetMySitePortalLayoutsUrl(string profileWebUrl, string relativePath) { return (UrlUtility.EnsureTrailingSlash(profileWebUrl) + (SPUtility.ContextLayoutsFolder + "/") + relativePath); } // Checks to see if the user has access to the User Profile links (will not render the links without) internal static bool CheckUserAccess { get { var upaProxyType = Type.GetType("Microsoft.Office.Server.Administration.UserProfileApplicationProxy, Microsoft.Office.Server.UserProfiles, Version=15.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c"); var userProfileManager = new UserProfileManager(SPServiceContext.Current, false, false); var prop = userProfileManager.GetType().GetProperty("UserProfileApplicationProxy", AllBindings); var proxy = prop.GetValue(userProfileManager, null); var upaProxyIsAvailableProperty = upaProxyType.GetMethod("IsAvailable", AllBindings); object[] parameters = { SPServiceContext.Current }; var upaProxyIsAvailable = (bool?) upaProxyIsAvailableProperty.Invoke(proxy, parameters); string item = GetItem("SocialData$CheckUserAccess"); if (!string.IsNullOrEmpty(item)) { return item.Equals(bool.TrueString); } if (upaProxyIsAvailable != true) { return false; } bool flag = false; if (proxy != null) { try { var upaProxyCheckAccessProperty = upaProxyType.GetMethod("CheckUserAccess", AllBindings); object[] accessParameters = { SPServiceContext.Current, 0L | 1L }; var upaProxyCheckAccess = (bool?) upaProxyCheckAccessProperty.Invoke(proxy, parameters); flag = upaProxyCheckAccess == true ? true : false; } catch (UserProfileException) { } } SetItem("SocialData$CheckUserAccess", flag.ToString()); return flag; } } // Collection of Binding Flags used for .NET reflection private static BindingFlags AllBindings { get { return BindingFlags.CreateInstance | BindingFlags.FlattenHierarchy | BindingFlags.GetField | BindingFlags.GetProperty | BindingFlags.IgnoreCase | BindingFlags.Instance | BindingFlags.InvokeMethod | BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.SetField | BindingFlags.SetProperty | BindingFlags.Static; } } } } -
Add a new Empty Element to create the Delegate Control override reference. (Right-click solution name > Add > New Item > Empty Element (in the Office/SharePoint group). For this example, I used “SuiteLinksControl” as the name of the element.
-
In the Elements.xml file of the Empty Element, we are using some XML to specify the Id of the Delegate Control to override and the source of our custom user control (which we haven’t created yet):
-
In the Solution Explorer, highligh the Empty Element (SuiteLinksControl). In the Properties window, select the […] button for the Safe Controls property to open the Safe Control Entries window.
-
Add a new Safe Control entry for our custom user control (which we haven’t created yet):
- Name: SharePointDelegatesControlTemplates
- Assembly: $SharePoint.Project.AssemblyFullName$
- Namespace: SharePointDelegates.CONTROLTEMPLATES.SharePointDelegates
- Safe: True
- Safe Against Script: True
- Type Name: *
-
When we add the Empty Element, a new Feature was automatically added. In the Solution Explorer rename it to something more meaningful (like SuiteLinksFeature).
-
Open the SuiteLinksFeature and set the scope to Farm. In addition, you should change the Title to something more meaningful (like “Custom Suite Links”). The Title is what gets displayed on the Features page when activating it.
-
Add the CONTROLTEMPLATES SharePoint mapped folder (right-click Solution Name > Add > Add SharePoint Mapped Folder > Expand TEMPLATE and Select CONTROLTEMPLATES.
-
To ensure no conflicts occur, add a new folder under the CONTROLTEMPLATES folder with the same name as the solution (like “SharePointDelegates”).
-
Add a new user control to the SharePointDelegates folder called “SuiteLinksControl.ascx”. (Right-click the SharePointDelegates folder > Add > New Item > User Control (Farm Solution only)
-
Open the SuiteLinksControl.ascx.cs file and add the following code:
using System; using System.Web.UI; using System.Web.UI.WebControls; using System.Web.UI.WebControls.WebParts; using Microsoft.SharePoint; using Microsoft.SharePoint.Portal.WebControls; using Microsoft.SharePoint.Utilities; using Microsoft.SharePoint.WebControls; using System.Globalization; using System.IO; using System.Collections; namespace SharePointDelegates.CONTROLTEMPLATES.SharePointDelegates { ///
/// The custom SuiteLinksControl copies most of its code from the default /// SuiteLinksDelegate control with a few items added for the custom links /// public partial class SuiteLinksControl : UserControl { // Links to Place Before Default Links void RenderLinksBefore(HtmlTextWriter writer) { //RenderSuiteLink(writer, UrlUtility.EnsureTrailingSlash(this.MySiteHostURL) + "Lookout.aspx", "Lookout", GetSuiteLinkControlId("ShellLookout")); RenderSuiteLink(writer, "http://www.bing.com", "Bing", GetSuiteLinkControlId("ShellBing")); } // Links to Place After Default Links void RenderLinksAfter(HtmlTextWriter writer) { RenderSuiteLink(writer, "http://msdn.microsoft.com", "MSDN", GetSuiteLinkControlId("ShellMsdn")); } // Default SharePoint link control IDs private const string allDocumentsLinkControlIdPart = "ShellDocuments"; private const string allSitesLinkControlIdPart = "ShellSites"; private const string newsfeedLinkControlIdPart = "ShellNewsfeed"; // Methods public string GetDesignTimeHtml() { this.SetControl(); StringWriter writer = new StringWriter(CultureInfo.CurrentCulture); HtmlTextWriter writer2 = new HtmlTextWriter(writer); this.Render(writer2); writer2.Close(); return writer.ToString(); } private string GetSuiteLinkControlId(string linkId) { return (this.ClientID + "_" + linkId); } protected override void OnInit(EventArgs e) { base.OnInit(e); if (SuiteLinksHelper.CheckUserAccess) { SuiteLinksHelper.EnsureProfileUrlsCached(); this.MySiteHostURL = SuiteLinksHelper.MySiteHostURL; this.AllDocumentsLinkControlId = this.GetSuiteLinkControlId("ShellDocuments"); this.AllSitesLinkControlId = this.GetSuiteLinkControlId("ShellSites"); this.NewsfeedLinkControlId = this.GetSuiteLinkControlId("ShellNewsfeed"); } } protected override void OnPreRender(EventArgs e) { this.SetControl(); base.OnPreRender(e); if (!string.IsNullOrEmpty(this.MySiteHostURL)) { ScriptLink.RegisterOnDemand(this, this.Page, "MyLinks.js", false); ScriptLink.RegisterOnDemand(this, this.Page, "sp.js", false); ScriptLink.RegisterOnDemand(this, this.Page, "SP.UI.MySiteNavigation.js", false); string script = string.Format(CultureInfo.InvariantCulture, "_spBodyOnLoadFunctions.push(function() {{ EnsureScriptParams('{0}', 'RenderMySiteLinksFromServer', '{1}', '{2}'); }});", new object[] { "MyLinks.js", SPHttpUtility.EcmaScriptStringLiteralEncode(this.AllDocumentsLinkControlId), SPHttpUtility.EcmaScriptStringLiteralEncode(this.AllSitesLinkControlId) }); SPPageContentManager.RegisterStartupScript(this, base.GetType(), "RenderMySiteLinksFromServer", script); } } protected override void Render(HtmlTextWriter writer) { if (!string.IsNullOrEmpty(this.MySiteHostURL)) { string url = SPUrlUtility.CombineUrl(this.MySiteHostURL, "/default.aspx"); string str2 = SPUrlUtility.CombineUrl(this.MySiteHostURL, SPUrlUtility.CombineUrl(SPUtility.RELATIVE_LAYOUTS_LATESTVERSION, "/MySite.aspx?MySiteRedirect=AllDocuments")); string str3 = SPUrlUtility.CombineUrl(this.MySiteHostURL, SPUrlUtility.CombineUrl(SPUtility.RELATIVE_LAYOUTS_LATESTVERSION, "/MySite.aspx?MySiteRedirect=AllSites")); writer.AddAttribute(HtmlTextWriterAttribute.Class, "ms-core-suiteLinkList"); writer.RenderBeginTag(HtmlTextWriterTag.Ul); // Render our custom before links first RenderLinksBefore(writer); RenderSuiteLink(writer, url, SuiteLinksHelper.GetString(LocStringId.MySuiteLinks_Newsfeed), this.NewsfeedLinkControlId); RenderSuiteLink(writer, str2, SuiteLinksHelper.GetString(LocStringId.MySuiteLinks_Documents), this.AllDocumentsLinkControlId); RenderSuiteLink(writer, str3, SuiteLinksHelper.GetString(LocStringId.MySuiteLinks_Sites), this.AllSitesLinkControlId); // Render our custom after links last RenderLinksAfter(writer); writer.RenderEndTag(); } } protected static void RenderSuiteLink(HtmlTextWriter writer, string url, string name, string linkId) { writer.AddAttribute(HtmlTextWriterAttribute.Class, "ms-core-suiteLink"); writer.RenderBeginTag(HtmlTextWriterTag.Li); writer.AddAttribute(HtmlTextWriterAttribute.Class, "ms-core-suiteLink-a"); writer.AddAttribute(HtmlTextWriterAttribute.Href, url); writer.AddAttribute(HtmlTextWriterAttribute.Id, linkId); writer.RenderBeginTag(HtmlTextWriterTag.A); writer.AddAttribute(HtmlTextWriterAttribute.Class, "ms-verticalAlignMiddle"); writer.RenderBeginTag(HtmlTextWriterTag.Span); writer.Write(name); writer.RenderEndTag(); writer.RenderEndTag(); writer.RenderEndTag(); } protected void SetControl() { if (!this.Page.IsCallback) { if (!SPUtility.IsCompatibilityLevel15Up) { this.Visible = false; } else if (string.IsNullOrEmpty(this.MySiteHostURL)) { this.Visible = false; } } } // Properties private string AllDocumentsLinkControlId { get; set; } private string AllSitesLinkControlId { get; set; } private string MySiteHostURL { get; set; } private string NewsfeedLinkControlId { get; set; } } } -
Now that we have our custom control ready to go we can deploy the solution and test. (Right-click the solution name > Deploy)
-
You should now see the new links at the top right corner of the page when you open your SharePoint site.
That’s it. There is a lot of opportunity to extend this idea (such as with an administration page to manage custom links). It is just unfortunate that Microsoft hard-coded these links rather than making them configurable.