diff --git a/djblets/forms/widgets.py b/djblets/forms/widgets.py
index 71abdf427e09bc99e5a436fe9f176b8a56ea49e8..9032e28c3783110e340abfa2d2281e4782b2e51d 100644
--- a/djblets/forms/widgets.py
+++ b/djblets/forms/widgets.py
@@ -9,17 +9,104 @@ from __future__ import unicode_literals
 import copy
 from contextlib import contextmanager
 
+from django.db.models import Model
 from django.forms import widgets
 from django.template.loader import render_to_string
 from django.utils import six
+from django.utils.safestring import mark_safe
 from django.utils.six.moves import range
 from django.utils.translation import ugettext as _
 
 from djblets.conditions import ConditionSet
 from djblets.conditions.errors import (ConditionChoiceNotFoundError,
                                        ConditionOperatorNotFoundError)
+from djblets.siteconfig.models import SiteConfiguration
 
 
+class RelatedObjectWidget(widgets.HiddenInput):
+
+    # We inherit from HiddenInput in order for the superclass to render a
+    # hidden <input> element, but the siteconfig field template special cases
+    # when ``is_hidden`` is True. Setting it to False still gives us the
+    # rendering and data handling we want but renders fieldset fields
+    # correctly.
+    is_hidden = True
+
+    def __init__(self, local_site_name=None):
+        super(RelatedObjectWidget, self).__init__()
+
+        self.local_site_name = local_site_name
+
+    def render(self, name, value, attrs=None):
+        """Render the widget.
+
+        Args:
+            name (unicode):
+                The name of the field.
+
+            value (list or None):
+                The current value of the field.
+
+            attrs (dict):
+                Attributes for the HTML element.
+
+        Returns:
+            django.utils.safestring.SafeText:
+            The rendered HTML.
+        """
+        if value:
+            value = [v for v in value if v]
+            input_value = ','.join(force_text(v) for v in value)
+            existing_users = (
+                Model.objects
+                .filter(pk__in=value)
+                .order_by('id')
+            )
+        else:
+            input_value = None
+            existing_users = []
+
+        final_attrs = self.build_attrs(attrs, name=name)
+
+        input_html = super(RelatedObjectWidget, self).render(
+            name, input_value, attrs)
+
+        # The Gravatar API in Djblets currently uses the request to determine
+        # whether or not to use https://secure.gravatar.com or
+        # http://gravatar.com. Unfortunately, it's hard enough to get a copy of
+        # the request in a form, much less in a form widget. Instead, we fake
+        # the request here and just always use the HTTPS one. This will be
+        # dramatically better in 3.0+ with the new avatar services.
+        class FakeRequest(object):
+            def is_secure(self):
+                return True
+
+        fake_request = FakeRequest()
+        siteconfig = SiteConfiguration.objects.get_current()
+        use_gravatars = siteconfig.get('integration_gravatars')
+        user_data = []
+
+        for user in existing_users:
+            data = {
+                'fullname': user.get_full_name(),
+                'id': user.pk,
+                'username': user.username,
+            }
+
+            if use_gravatars:
+                data['avatar_url'] = get_gravatar_url(fake_request, user, 40)
+
+            user_data.append(data)
+
+        extra_html = render_to_string('admin/related_user_widget.html', {
+            'input_id': final_attrs['id'],
+            'local_site_name': self.local_site_name,
+            'use_gravatars': use_gravatars,
+            'users': user_data,
+        })
+
+        return mark_safe(input_html + extra_html)
+
 class ConditionsWidget(widgets.Widget):
     """A widget used to request a list of conditions from the user.
 
