By popular demand, below is a technical description and source code of the new Community Activity Map that I posted a couple of weeks ago.
Overview
I started on this back in December 2010 when we were refreshing the community landing page. Having a list of recent issues, and announcements is all well and good, but there is so much more happening in our worldwide community than the static content I can create, so I wanted a good way to dynamically visualize all of the activity. Liferay has a built-in activity stream framework, and indeed activities can already be seen in a listing (for example, on my profile page on liferay.com). However, it is a static display and only relates to my activities. Wouldn't it be great to see everyone's activities, and the global location at which these activities occur? Liferay and Google Maps to the rescue!
Portlet or Web Content
Initially, I created a simple MVC-based portlet with a single JSP page. On the server side, the JSP contained a scriptlet that fetched activities using Liferay's SocialActivity service, and created a giant javascript object in the resulting markup, containing around 20 activities. The markup is then shipped to the client side, where some more javascript rendered the activities on a google map. This was all well and good, but unless you refreshed the page, you'd always see the same 20 activities, in a loop, forever. It also meant that render time was slower because all of the action occured on the server side, and the client didn't see anything until the activities were already fetched and processed.
The other problem was I didn't test it on IE, and when I showed it to Brian Chan, he tried it on IE and predictably it didn't work.
I abandoned this work due to other priorities, but then about a month ago I found some spare time and resurrected the code. This time, I decided to make it closer to realtime, and update the page dynamically, so I switched to an AJAX mechanism, which fetched activities using the portlet's serveResource API in an AJAX call (using AlloyUI's built-in AJAX IO library). I made sure it worked on IE, and submitted the code for review to Peter Shin. Peter, being the awesome developer he is, thought this would be simpler to maintain if it were purely web content using Liferay's powerful Web Content Management system. He was able to convert my portlet into pure Velocity-based web content (similar to Ray's example from a few months ago). This means that its much easier to make updates, and the liferay.com maintainers don't have to be bothered every time I wanted to re-deploy a new version. Nice, and immense thanks goes to Peter for this!
Web Content Framework
To create apps using Liferay WCM, you create web content structures and templates and articles (this should be familiar to anyone that has used Liferay WCM). In this case, our structure is simple, and contains a single configurable element called height. This is then referenced in the template using $height.data. You could add other configurable parameters (for example, configurable timeouts, or configurable Google Map options, etc). The web content template has the following framework:
#if ($request.lifecycle == "RENDER_PHASE") ## Client-side initial render call will render the result of this code #elseif ($request.lifecycle == "RESOURCE_PHASE") ## Client-side AJAX call will fetch the result of this code #end
RENDER_PHASE code is akin to a portlet's "View Mode" code (typically a JSP called view.jsp), and the RESOURCE_PHASE is akin to a portlet's serveResource code.
In this case, the RENDER_PHASE contains the javascript for fetching activities via an AlloyUI AJAX call, and then rendering the Google Map, and placing "bubbles" on the map at varying intervals.
The RESOURCE_PHASE results in a JSON object, which is read by code in the RENDER_PHASE, which contains the activities.
When this is all put together, the client side gets the RENDER_PHASE javascript, and executes it, which causes the AJAX call back to Liferay, and returns the result of the RESOURCE_PHASE code. It's important to NOT make the template cachable, such that every AJAX call results in a new execution of the RESOURCE_PHASE call (otherwise, the same activities would be returned, time and time again).
Fetching and Rendering Activities
The important part of the RESOURCE_PHASE code is here:
#set ($socialActivities = $socialActivityLocalService.getGroupActivities($scopeGroupId, 0, 50)) #foreach ($socialActivity in $socialActivities) #set ($socialActivityFeedEntry = $socialActivityInterpreterLocalService.interpret($socialActivity, $themeDisplay)) ## do stuff with it #end
- body - The rendered content for the activity (rendered via the
socialActivityInterpreterLocalService.interpret()call) - description - The description of the time of the activity (e.g. "A few seconds ago", "Yesterday", or a date/time)
- geocoderAddress - The location information for the user who did the activity (more on this below)
- title - The title of the activity
- userDisplayURL - The user's profile URL
- userFullName - The user's name
- userPortraitURL - The URL to the user's profile picture
RESOURCE_PHASE looks like
{
"jsonArray": [{"body":"....", "geocoderAddress", "France", ...}, {...more activities here...}]
}
Geocoder Details
#foreach ($address in $user.getAddresses())
#if ($address.isPrimary())
#set ($city = $address.getCity())
#set ($region = $address.getRegion().getName())
#set ($country = $address.getCountry().getName())
## more stuff
#end
#end
#set ($country = $user.getExpandoBridge().getAttribute("country"))
Time Description
#set ($now = $dateUtil.newDate()) #set ($millisecondsBetween = $now.getTime() - $socialActivity.getCreateDate()) #if ($millisecondsBetween < 60000) #set ($description = $languageUtil.get($locale, "a-few-seconds-ago")) #elseif .... ## more strings for different time differences #end
Making the AJAX call
RESOURCE_PHASE code, using the below javascript:
function ${portletNamespace}getSocialActivities() {
AUI().use(
"aui-base", "aui-io-plugin", "aui-io-request",
function(A) {
A.io.request(
"${request.resource-url}",
{
data: {
},
dataType: "json",
on: {
success: function(event, id, obj) {
var responseData = this.get("responseData");
${portletNamespace}socialActivityCache = responseData.jsonArray || [];
${portletNamespace}renderSocialActivity();
},
failure: function(event, id, obj) {
}
}
}
);
}
);
}
renderSocialActivity() function is called to loop through the activities.
Rendering the map
var ${portletNamespace}googleMap = new google.maps.Map(
document.getElementById("${portletNamespace}map"),
{
center: new google.maps.LatLng(35, 0),
mapTypeControl: false,
mapTypeId: google.maps.MapTypeId.ROADMAP,
navigationControl: false,
scaleControl: false,
streetViewControl: false,
zoom: 2
}
function ${portletNamespace}openInfoWindow(position, content, zIndex) {
var infoWindow = new google.maps.InfoWindow(
{
content: content,
position: position
}
);
infoWindow.setOptions(
{
disableAutoPan: false,
zIndex: zIndex
}
);
infoWindow.open(${portletNamespace}googleMap);
setTimeout(
function() {
infoWindow.close();
},
15000
);
}
Notice the hard-coded values for timeouts. This could be improved by making it part of the web content's structure (like height), so that it could be configured easier. Also notice the zIndex argument - this makes sure that newer bubbles appear on top of older bubbles.
The geocoderAddress returned as part of the JSON object (from the RESOURCE_PHASE) is geocoded using Google's geocoder API:
geocoder.geocode(
{"address": geocoderAddress},
function(results, status) {
if (status == google.maps.GeocoderStatus.OK) {
${portletNamespace}openInfoWindow(results[0].geometry.location, content, ${portletNamespace}index);
${portletNamespace}geocoderAddressCache[geocoderAddress] = results[0].geometry.location;
}
}
);
Timeouts
setTimeout("${portletNamespace}renderSocialActivity()", 10000 + (Math.floor(Math.random() * 10000)));
Random Notes and Improvements
RESOURCE_PHASE, because of the nuances of Liferay's WCM (in particular, because you don't have access to a "live" ThemeDisplay object, we have to create one and populate it with necessary content from the "sparse" name/value theme-display property available from the request object, which is available to WCM templates). Don't let it distract you!
- Making more things configurable, like the timeouts, map options, etc
- Making it truly real-time using a ModelListener Hook as described above
- Fetching more kinds of activities (for example, right now, the map won't show blog entries, since these occur in the user's "group", not the liferay.com group)
- Accessing activities from outside of liferay.com! Perhaps integrating with your Facebook profile, the Liferay twitter stream, or some other activity feed
- Others?
The full Source Code
- Create a web content structure, and cut and paste the below structure code into it.
- Create a web content template, associate it with the above newly-created structure, and cut and paste the below Velocity code into your template.
- Create a new web content article, selecting the above structure and template.
- Add a "Web Content Display" portlet to a page, and configure it to show the newly created article created in step 3.
#set ($portletNamespace = $request.portlet-namespace)
#set ($scopeGroupId = $getterUtil.getLong($request.theme-display.scope-group-id))
#set ($timeZone = $timeZoneUtil.getTimeZone($request.theme-display.time-zone))
#set ($userId = $getterUtil.getLong($request.theme-display.user-id))
#set ($height = $getterUtil.getString($height.data, "300"))
#if ($request.lifecycle == "RENDER_PHASE")
<link href="//code.google.com/apis/maps/documentation/javascript/examples/standard.css" rel="stylesheet" type="text/css" />
<script type="text/javascript" src="//maps.google.com/maps/api/js?sensor=false"></script>
<div id="${portletNamespace}map" style="height: ${height}px; margin-bottom: 1.5em; width: 100%;"><!-- --></div>
<script type="text/javascript">
var ${portletNamespace}geocoderAddressCache = new Object();
var ${portletNamespace}googleMap = new google.maps.Map(
document.getElementById("${portletNamespace}map"),
{
center: new google.maps.LatLng(35, 0),
mapTypeControl: false,
mapTypeId: google.maps.MapTypeId.ROADMAP,
navigationControl: false,
scaleControl: false,
streetViewControl: false,
zoom: 2
}
);
google.maps.event.addDomListener(window, "load", ${portletNamespace}getSocialActivities);
var ${portletNamespace}index = 0;
var ${portletNamespace}socialActivityCache = [];
function ${portletNamespace}getSocialActivities() {
AUI().use(
"aui-base", "aui-io-plugin", "aui-io-request",
function(A) {
A.io.request(
"${request.resource-url}",
{
data: {
},
dataType: "json",
on: {
success: function(event, id, obj) {
var responseData = this.get("responseData");
${portletNamespace}socialActivityCache = responseData.jsonArray || [];
${portletNamespace}renderSocialActivity();
},
failure: function(event, id, obj) {
}
}
}
);
}
);
}
function ${portletNamespace}openInfoWindow(position, content, zIndex) {
var infoWindow = new google.maps.InfoWindow(
{
content: content,
position: position
}
);
infoWindow.setOptions(
{
disableAutoPan: false,
zIndex: zIndex
}
);
infoWindow.open(${portletNamespace}googleMap);
setTimeout(
function() {
infoWindow.close();
},
15000
);
}
function ${portletNamespace}renderSocialActivity() {
if (${portletNamespace}socialActivityCache.length <= 0) {
${portletNamespace}openInfoWindow(new google.maps.LatLng(35, 0), Liferay.Language.get("there-are-no-recent-activities"), 1);
return;
}
if (${portletNamespace}index >= ${portletNamespace}socialActivityCache.length) {
${portletNamespace}index = 0;
setTimeout("${portletNamespace}getSocialActivities()", 1000);
return;
}
var content = '<div>' +
' <a href="' + ${portletNamespace}socialActivityCache[${portletNamespace}index].userDisplayURL + '">' +
' <img alt="' + ${portletNamespace}socialActivityCache[${portletNamespace}index].userFullName + '" style="float: left;" height="44" hspace="4" vspace="4" src="' + ${portletNamespace}socialActivityCache[${portletNamespace}index].userPortraitURL + '" />' +
' </a>' +
' <div>' +
${portletNamespace}socialActivityCache[${portletNamespace}index].description +
' </div>' +
' <div>' +
${portletNamespace}socialActivityCache[${portletNamespace}index].title +
' </div>' +
' <div>' +
${portletNamespace}socialActivityCache[${portletNamespace}index].body +
' </div>' +
'</div>';
var geocoderAddress = ${portletNamespace}geocoderAddressCache[${portletNamespace}socialActivityCache[${portletNamespace}index].geocoderAddress];
if (geocoderAddress) {
${portletNamespace}openInfoWindow(geocoderAddress, content, ${portletNamespace}index);
}
else {
var geocoder = new google.maps.Geocoder();
geocoderAddress = ${portletNamespace}socialActivityCache[${portletNamespace}index].geocoderAddress;
geocoder.geocode(
{"address": geocoderAddress},
function(results, status) {
if (status == google.maps.GeocoderStatus.OK) {
${portletNamespace}openInfoWindow(results[0].geometry.location, content, ${portletNamespace}index);
${portletNamespace}geocoderAddressCache[geocoderAddress] = results[0].geometry.location;
}
}
);
}
${portletNamespace}index = ${portletNamespace}index + 1;
setTimeout("${portletNamespace}renderSocialActivity()", 10000 + (Math.floor(Math.random() * 10000)));
}
</script>
#elseif ($request.lifecycle == "RESOURCE_PHASE")
#set ($logFactory = $portal.getClass().forName("com.liferay.portal.kernel.log.LogFactoryUtil"))
#set ($log = $logFactory.getLog("com.liferay.portal.util.PortalImpl"))
#set ($portalBeanLocator = $portal.getClass().forName("com.liferay.portal.kernel.bean.PortalBeanLocatorUtil"))
#set ($portletBeanLocator = $portal.getClass().forName("com.liferay.portal.kernel.bean.PortletBeanLocatorUtil"))
#set ($fastDateFormatFactoryUtil = $portal.getClass().forName("com.liferay.portal.kernel.util.FastDateFormatFactoryUtil"))
#set ($jsonFactory = $portalBeanLocator.locate("com.liferay.portal.kernel.json.JSONFactoryUtil"))
#set ($permissionThreadLocal = $portal.getClass().forName("com.liferay.portal.security.permission.PermissionThreadLocal"))
#set ($socialActivityInterpreterLocalService = $portalBeanLocator.locate("com.liferay.portlet.social.service.SocialActivityInterpreterLocalService.velocity"))
#set ($socialActivityLocalService = $portalBeanLocator.locate("com.liferay.portlet.social.service.SocialActivityLocalService.velocity"))
#set ($userLocalService = $portalBeanLocator.locate("com.liferay.portal.service.UserLocalService.velocity"))
#set ($dateFormatDateTime = $fastDateFormatFactoryUtil.getDateTime(1, 3, $locale, $timeZone))
#set ($portalURL = $httpUtil.getProtocol($request.attributes.CURRENT_URL) + "://" + $getterUtil.getString($request.theme-display.portal-url))
#set ($themeDisplay = $portal.getClass().forName("com.liferay.portal.theme.ThemeDisplay").newInstance())
#set ($V = $themeDisplay.setLocale($locale))
#set ($V = $themeDisplay.setPathImage($getterUtil.getString($request.theme-display.path-image)))
#set ($V = $themeDisplay.setPathMain($getterUtil.getString($request.theme-display.path-main)))
#set ($V = $themeDisplay.setPermissionChecker($permissionThreadLocal.getPermissionChecker()))
#set ($V = $themeDisplay.setPortalURL($portalURL))
#set ($V = $themeDisplay.setScopeGroupId($scopeGroupId))
#set ($V = $themeDisplay.setTimeZone($request.theme-display.time-zone))
#set ($V = $themeDisplay.setUser($userLocalService.getUserById($userId)))
#set ($socialActivities = $socialActivityLocalService.getGroupActivities($scopeGroupId, 0, 50))
#set ($jsonArray = $jsonFactory.createJSONArray())
#foreach ($socialActivity in $socialActivities)
#set ($socialActivityFeedEntry = $socialActivityInterpreterLocalService.interpret($socialActivity, $themeDisplay))
#if ($validator.isNotNull($socialActivityFeedEntry))
#set ($user = $userLocalService.getUserById($socialActivity.getUserId()))
#set ($geocoderAddress = "")
#foreach ($address in $user.getAddresses())
#if ($address.isPrimary())
#set ($city = $address.getCity())
#set ($region = $address.getRegion().getName())
#set ($country = $address.getCountry().getName())
#set ($s = "")
#if ($validator.isNotNull($city))
#set ($s = $s + $city + ",")
#end
#if ($validator.isNotNull($region))
#set ($s = $s + $region + ",")
#end
#if ($validator.isNotNull($country))
#set ($s = $s + $country)
#end
#if ($validator.isNotNull($s))
#set ($geocoderAddress = $s)
#end
#end
#end
#if ($validator.isNull($geocoderAddress))
#set ($country = $user.getExpandoBridge().getAttribute("country"))
#if ($validator.isNotNull($country))
#set ($geocoderAddress = $languageUtil.get($locale, $stringUtil.merge($country)))
#end
#end
#if ($validator.isNull($geocoderAddress))
#set ($geocoderAddress = "Walnut, CA, United States of America")
#end
#set ($now = $dateUtil.newDate())
#set ($millisecondsBetween = $now.getTime() - $socialActivity.getCreateDate())
#set ($description = $dateFormatDateTime.format($socialActivity.getCreateDate()))
#if ($millisecondsBetween < 60000)
#set ($description = $languageUtil.get($locale, "a-few-seconds-ago"))
#if ($validator.equals($description, "a-few-seconds-ago"))
#set ($description = "A few seconds ago.")
#end
#elseif ($millisecondsBetween < 3600000)
#set ($minutes = $millisecondsBetween / 60000)
#set ($description = $languageUtil.format($locale, "about-x-minutes-ago", $stringUtil.merge([$minutes]), false))
#if ($validator.equals($description, "about-x-minutes-ago"))
#set ($description = "About " + $minutes + " minute(s) ago.")
#end
#elseif ($millisecondsBetween < 86400000)
#set ($hours = $millisecondsBetween / 3600000)
#set ($description = $languageUtil.format($locale, "about-x-hours-ago", $stringUtil.merge([$hours]), false))
#if ($validator.equals($description, "about-x-hours-ago"))
#set ($description = "About " + $hours + " hour(s) ago.")
#end
#elseif ($millisecondsBetween < 604800000)
#set ($days = $dateUtil.getDaysBetween($dateUtil.newDate($socialActivity.getCreateDate()), $now, $timeZone))
#set ($description = $languageUtil.format($locale, "about-x-days-ago", $stringUtil.merge([$days]), false))
#if ($validator.equals($description, "about-x-days-ago"))
#set ($description = "About " + $days + " day(s) ago.")
#end
#end
#set ($jsonObject = $jsonFactory.createJSONObject())
#set ($V = $jsonObject.put("body", $socialActivityFeedEntry.getBody()))
#set ($V = $jsonObject.put("description", $description))
#set ($V = $jsonObject.put("geocoderAddress", $geocoderAddress))
#set ($V = $jsonObject.put("title", $socialActivityFeedEntry.getTitle()))
#set ($V = $jsonObject.put("userDisplayURL", $user.getDisplayURL($themeDisplay)))
#set ($V = $jsonObject.put("userFullName", $htmlUtil.escape($user.getFullName())))
#set ($V = $jsonObject.put("userPortraitURL", $user.getPortraitURL($themeDisplay)))
#set ($V = $jsonArray.put($jsonObject))
#end
#end
{
"jsonArray": $jsonArray
}
#end
The WCM structure is pretty darn simple:
<root> <dynamic-element name='height' type='text' index-type='' repeatable='false'/> </root>
That's all folks!

