Composr Supplementary: Making an addon (part 3)

Written by Chris Graham (ocProducts)
Welcome to the third of our series of addon making tutorials. If you haven't yet read the prior tutorials then it's advisable that you do so before reading this one.

Showing an expanded users-online display in Conversr

A similar feature to this was added to ocPortal 4, so this example is no longer usable and has been left to get out of date. But it is still a good tool for learning.

Today we're going to make a modification to how Conversr shows the online users at the bottom of the forum-view.
We're going to colour-code each user there against usergroup, and give usergroup colour-keys beneath.

This example is much more involved than the ones given in the previous tutorial. The previous addons were straight-forward as we were just adding new modular functionality into the Composr framework – now we are extending and changing existing behaviour. Right now I don't know how I'll do this, and my explanations will follow my train of logic. I've intentionally done this to help you to learn to think like a programmer like me thinks.

In order to get an idea of what to do we need to consider what we're influencing. To find this out I'll need to go and find the Composr code that handles the user-online functionality that we're changing. Right now I've just opened up my web browser to and scrolled to the bottom. I see we have the text 'Users online:' there, near the list that we need to change. This doesn't help us find the code because this is a language string and thus not stored where the actual code is stored. If I can find some unique HTML I'll be able to find the template and then use the name of that template to find the PHP code (there are other ways to do it, but this way is what I am most comfortable doing). I'm opening up the HTML page source and searching for that 'Users online:' phrase, and I've found <td class="cns_stats_usersonline_2">.

Next I do a file contents search in Windows Explorer, in the themes/default/templates directory for the string I found. The search gives me CNS_STATS.tpl. I open up this in my editor as I'll probably need it. Now I want to know where this template is used in the PHP code, so I do another file search, this time in the Composr root directory, for all PHP files containing do_template('CNS_STATS'. This search reveals a match in sources/cns_general.php, so I open that up in my editor and I use the editor search feature to go to the exact line, which turns out to be line 418.

We now have PHP code and a template, and these are probably all we need to be modifying. Because this is an addon, we should be overriding files rather than editing them directly, otherwise we wouldn't be able to revert, and upgrading Composr would be awkward because we wouldn't know what we had changed (everything old and new would be lumped together). Therefore I copy themes/default/templates/CNS_STATS.tpl to themes/default/templates_custom/CNS_STATS.tpl, and I copy the PHP function in sources/cns_general.php that uses that template (cns_wrapper) into a new PHP file, sources_custom/cns_general.php, between new <?php and ?> markers. Our sources_custom/cns_general.php uses function-level overriding, which is much more effective because it reduces the chance of bug fixes to the original copy being non-effective. This is because less is overridden, and thus less verbatim-Composr-code is masked.

I remove the old non-overridden files from my editor, and open up my copies instead.

Now all we need to do is to read over Composr's cns_wrapper function and CNS_STATS.tpl template to get an understanding of how they work. I can't explain all that here – you need to have built up your own code understanding so you can figure it out yourself. In fact, after reading the code I've found I'm also going to need to override the CNS_USER_MEMBER.tpl template, so I do for that what I did for CNS_STATS.tpl.

Knowing how to program, I now have written my code, with my modified files saved as following:


Code (HTML)

{+START,IF_PASSED,AT}<a {+START,IF_PASSED,COLOUR}style="color: {COLOUR}" {+END}title="{AT#}" href="{PROFILE_URL*}">{USERNAME*}</a>{+END}{+START,IF_NON_PASSED,AT}<a {+START,IF_PASSED,COLOUR}style="color: {COLOUR}" {+END}title="{!MEMBER}" href="{PROFILE_URL*}">{USERNAME*}</a>{+END}<span class="accessibility_hidden">, </span>


Code (HTML)

<br />

<div class="box guid_{_GUID}">
   <div class="box_inner">

      <div class="cns_stats_1">
         <table cellpadding="0" cellspacing="0" class="map_table cns_stats_2">
            <colgroup span="2">
               <col class="cns_bottom_bar_left_column" />
               <col class="cns_bottom_bar_right_column" />

               <th class="de_th cns_column1 cns_stats_usersonline_1">
                  <span class="field_name">{!USERS_ONLINE}:</span><br />
                  <span class="associated_link">[ <a href="{USERS_ONLINE_URL*}">{!DETAILS}</a> ]</span>
               <td class="cns_stats_usersonline_2">

                     {+START,LOOP,GROUPS}{+START,IF,{$NOT,{$GET,doing_first_group}}}, {+END}<a style="color: {GCOLOUR*}" href="{$PAGE_LINK*,_SEARCH:groups:view:{GID}}">{GTITLE*}</a>{$SET,doing_first_group,0}{+END}
               <th class="de_th cns_column1 cns_stats_main_1">
               <td class="cns_stats_main_2">

<br />


Code (PHP)


 * Do the wrapper that fits around Conversr module output.
 * @param  tempcode $title The title for the module output that we are wrapping.
 * @param  tempcode $content The module output that we are wrapping.
 * @param  boolean $show_personal_bar Whether to include the personal bar in the wrap.
 * @param  boolean $show_stats Whether to include statistics in the wrap.
 * @param  ?AUTO_LINK $forum_id The forum to make the search link search under (null: Users own PT forum/unknown).
 * @return tempcode The wrapped output.

function cns_wrapper($title, $content, $show_personal_bar = true, $show_stats = true, $forum_id = null)

    global $ZONE;
    $wide = (get_param_integer('wide', get_param_integer('wide_high', 0)) == 1);
    if (!$wide) {
        $show_personal_bar = false;
        $show_stats = false;

    // Notifications
    if ((get_member() != $GLOBALS['CNS_DRIVER']->get_guest_id()) && ((get_page_name() == 'forumview') || (get_page_name() == 'topicview'))) {
        $cache_identifier = serialize(array(get_member()));
        $_notifications = null;
        if (get_option('is_on_block_cache') == '1') {
            $_notifications = get_cache_entry('_new_pp', $cache_identifier);
        if (is_null($_notifications)) {
            $unread_pps = cns_get_pp_rows();
            $notifications = new Tempcode();
            $num_unread_pps = 0;
            foreach ($unread_pps as $unread_pp) {
                $by = $GLOBALS['CNS_DRIVER']->get_username($unread_pp['p_poster']);
                if (is_null($by)) {
                    $by = do_lang('UNKNOWN');
                $u_title = $unread_pp['t_cache_first_title'];
                if (is_null($unread_pp['t_forum_id'])) {
                    $type = do_lang(($unread_pp['t_cache_first_post_id'] == $unread_pp['id']) ? 'NEW_PT_NOTIFICATION' : 'NEW_PP_NOTIFICATION');
                    $reply_url = build_url(array('page' => 'topics', 'type' => 'new_post', 'id' => $unread_pp['p_topic_id'], 'quote' => $unread_pp['id']), get_module_zone('topics'));
                } else {
                    $type = do_lang('NEW_INLINE_PERSONAL_POST');
                    if ($unread_pp['p_title'] != '') {
                        $u_title = $unread_pp['p_title'];
                    $reply_url = build_url(array('page' => 'topics', 'type' => 'new_post', 'id' => $unread_pp['p_topic_id'], 'quote' => $unread_pp['id'], 'intended_solely_for' => $unread_pp['p_poster']), get_module_zone('topics'));
                $time_raw = $unread_pp['p_time'];
                $time = do_timezoned_date($unread_pp['p_time']);
                $topic = $GLOBALS['CNS_DRIVER']->post_url($unread_pp['id'], null);
                $post = cns_clean_post_for_tooltip(text_lookup_comcode($unread_pp['p_post'], $GLOBALS['FORUM_DB']));
                $description = $unread_pp['t_description'];
                if ($description != '') {
                    $description = ' (' . $description . ')';
                $profile_link = $GLOBALS['CNS_DRIVER']->member_profile_url($unread_pp['p_poster']);
                $redirect_url = get_self_url(true, true);
                $ignore_url = build_url(array('page' => 'topics', 'type' => 'mark_read_topic', 'id' => $unread_pp['p_topic_id'], 'redirect' => $redirect_url), get_module_zone('topics'));
                $notifications->attach(do_template('CNS_NOTIFICATION', array(
                    '_GUID' => '3b224ea3f4da2f8f869a505b9756970a',
                    'ID' => strval($unread_pp['id']),
                    'U_TITLE' => $u_title,
                    'IGNORE_URL' => $ignore_url,
                    'REPLY_URL' => $reply_url,
                    'TOPIC_URL' => $topic,
                    'POST' => $post,
                    'DESCRIPTION' => $description,
                    'TIME' => $time,
                    'TIME_RAW' => strval($time_raw),
                    'BY' => $by,
                    'PROFILE_LINK' => $profile_link,
                    'TYPE' => $type,

            put_into_cache('_new_pp', 60 * 60 * 24, $cache_identifier, array($notifications, $num_unread_pps));
        } else {
            list($notifications, $num_unread_pps) = $_notifications;
    } else {
        $notifications = new Tempcode();
        $num_unread_pps = 0;

    if ($show_personal_bar) {
        if (get_member() != $GLOBALS['CNS_DRIVER']->get_guest_id()) { // Logged in user
            $member_info = cns_read_in_member_profile(get_member(), true);

            $profile_url = $GLOBALS['CNS_DRIVER']->member_profile_url(get_member());

            $zone_chooser = get_zone_chooser(true);

            $_new_topics = $GLOBALS['FORUM_DB']->query('SELECT COUNT(*) AS mycnt FROM ' . $GLOBALS['FORUM_DB']->get_table_prefix() . 'f_topics WHERE t_forum_id IS NOT NULL AND t_cache_first_time>' . (string)intval($member_info['last_visit_time']));
            $new_topics = $_new_topics[0]['mycnt'];
            $_new_posts = $GLOBALS['FORUM_DB']->query('SELECT COUNT(*) AS mycnt FROM ' . $GLOBALS['FORUM_DB']->get_table_prefix() . 'f_posts WHERE p_cache_forum_id IS NOT NULL AND p_time>' . (string)intval($member_info['last_visit_time']));
            $new_posts = $_new_posts[0]['mycnt'];

            // Any unread PT-PPs?
            $pt_extra = ($num_unread_pps == 0) ? '' : do_lang('NUM_UNREAD', number_format($num_unread_pps));

            $private_topic_url = build_url(array('page' => 'members', 'type' => 'view', 'id' => get_member()), get_module_zone('members'), null, true, false, false, 'tab__pts')

            $head = do_template('CNS_MEMBER_BAR', array(
                '_GUID' => 's3kdsadf0p3wsjlcfksdj',
                'AVATAR' => array_key_exists('avatar', $member_info) ? $member_info['avatar'] : '',
                'PROFILE_URL' => $profile_url,
                'USERNAME' => $member_info['username'],
                'LOGOUT_URL' => build_url(array('page' => 'login', 'type' => 'logout'), get_module_zone('login')),
                'NUM_POINTS_ADVANCE' => array_key_exists('num_points_advance', $member_info) ? number_format($member_info['num_points_advance']) : do_lang('NA'),
                'NUM_POINTS' => number_format($member_info['points']),
                'NUM_POSTS' => number_format($member_info['posts']),
                'PRIMARY_GROUP' => $member_info['primary_group_name'],
                'LAST_VISIT_DATE_RAW' => strval($member_info['last_visit_time']),
                'LAST_VISIT_DATE' => $member_info['last_visit_time_string'],
                'PRIVATE_TOPIC_URL' => $private_topic_url,
                'NEW_POSTS_URL' => build_url(array('page' => 'vforums', 'type' => 'browse'), get_module_zone('vforums')),
                'UNREAD_TOPICS_URL' => build_url(array('page' => 'vforums', 'type' => 'unread'), get_module_zone('vforums')),
                'UNANSWERED_TOPICS_URL' => build_url(array('page' => 'vforums', 'type' => 'unanswered'), get_module_zone('vforums')),
                'INVOLVED_TOPICS_URL' => build_url(array('page' => 'vforums', 'type' => 'involved'), get_module_zone('vforums')),
                'PT_EXTRA' => $pt_extra,
                'NEW_TOPICS' => number_format($new_topics),
                'NEW_POSTS' => number_format($new_posts)
        } else { // Guest
            $_this_url = build_url(array('page' => '_SELF'), '_SELF', null, true);
            $this_url = $_this_url->evaluate();
            $login_url = build_url(array('page' => 'login', 'type' => 'login', 'redirect' => $this_url), get_module_zone('login'));
            $full_link = build_url(array('page' => 'login', 'type' => 'browse', 'redirect_passon' => $this_url), get_module_zone('login'));
            $join_url = build_url(array('page' => 'join'), get_module_zone('join'));
            $head = do_template('CNS_GUEST_BAR', array('NAVIGATION' => get_zone_chooser(true), 'LOGIN_URL' => $login_url, 'JOIN_LINK' => $join_url, 'FULL_LINK' => $full_link));
    } else {
        $head = new Tempcode();

    if ($show_stats) {
        $stats = cns_get_forums_stats();

        // Colours for various usergroup IDs -- we'll do 16, but we'll repeat 4 times in case we have up to 64 usergroups
        $all_colours = array('#FAA500', '#FA0C00', '#FA00F1', '#5800FA', '#0099FA', '#00FAA5', '#2BAB03', '#7D7E04', '#966038', '#96384A', '#963895', '#4E3896', '#386296', '#389596', '#389653', '#859638');
        $all_colours = array_merge($all_colours, $all_colours, $all_colours, $all_colours);

        // Users online
        $users_online = new Tempcode();
        $members = get_users_online();
        $members = collapse_2d_complexity('member_id', 'cache_username', $members);
        $guests = 0;
        foreach ($members as $member => $username) {
            if ($member == $GLOBALS['CNS_DRIVER']->get_guest_id()) {
            if (is_null($username)) {
            $url = $GLOBALS['CNS_DRIVER']->member_profile_url($member);
            $pgid = $GLOBALS['FORUM_DRIVER']->get_member_row_field($member, 'm_primary_group');
            $users_online->attach(do_template('CNS_USER_MEMBER', array('_GUID' => 'a9cb1af2a04b14edd70749c944495bff', 'FIRST' => $users_online->is_empty(), 'COLOUR' => $all_colours[$pgid], 'PROFILE_URL' => $url, 'USERNAME' => $username)));
        if ($guests != 0) {
            if (!$users_online->is_blank()) {
                $users_online->attach(', ');
            $users_online->attach(do_lang('NUM_GUESTS', number_format($guests)));

        // Birthdays
        $_birthdays = cns_find_birthdays();
        $birthdays = new Tempcode();
        foreach ($_birthdays as $_birthday) {
            $birthday = do_template('CNS_USER_MEMBER', array('_GUID' => 'a98959187d37d80e134d47db7e3a52fa', 'FIRST' => $birthdays->is_empty(), 'PROFILE_URL' => $GLOBALS['CNS_DRIVER']->member_profile_url($_birthday['id']), 'USERNAME' => $_birthday['username']));
            if (array_key_exists('age', $_birthday)) {
                $birthday->attach(' (' . $_birthday['age'] . ')');
        if (!$birthdays->is_blank()) {
            $birthdays = do_template('CNS_BIRTHDAYS', array('_GUID' => '03da2c0d46e76407d63bff22aac354bd', 'BIRTHDAYS' => $birthdays));

        // Usergroup keys
        $groups = array();
        $all_groups = $GLOBALS['FORUM_DRIVER']->get_usergroup_list();
        foreach ($all_groups as $gid => $gtitle) {
            if ($gid == db_get_first_id()) {
                continue; // Throw out the first, guest usergroup
            $groups[] = array('GCOLOUR' => $all_colours[$gid], 'GID' => strval($gid), 'GTITLE' => $gtitle);

        $foot = do_template('CNS_STATS', array(
            '_GUID' => 'sdflkdlfd303frksdf',
            'NEWEST_MEMBER_PROFILE_URL' => $GLOBALS['CNS_DRIVER']->member_profile_url($stats['newest_member_id']),
            'NEWEST_MEMBER_USERNAME' => $stats['newest_member_username'],
            'NUM_MEMBERS' => number_format($stats['num_members']),
            'NUM_TOPICS' => number_format($stats['num_topics']),
            'NUM_POSTS' => number_format($stats['num_posts']),
            'BIRTHDAYS' => $birthdays,
            'USERS_ONLINE' => $users_online,
            'USERS_ONLINE_URL' => build_url(array('page' => 'users_online'), get_module_zone('users_online')),
            'GROUPS' => $groups
    } else {
        $foot = new Tempcode();

    $wrap = do_template('CNS_WRAPPER', array('_GUID' => '456c21db6c09ae260accfa4c2a59fce7', 'TITLE' => $title, 'NOTIFICATIONS' => $notifications, 'HEAD' => $head, 'FOOT' => $foot, 'CONTENT' => $content));

    return $wrap;


Our functionality modification in action.

Our functionality modification in action.

(Click to enlarge)

As usual I won't explain all the code, but I will mention a few things:
  • The main PHP code doesn't have to do much more than define some colours and pass in a usergroup list. Almost all the code in my file is just code from the original cns_wrapper function
  • I passed a new parameter into CNS_USER_MEMBER.tpl and used that to indicate the colour to display a member link with; however, I wrapped a Tempcode IF_PASSED directive around the use of the parameter because the template is reused and won't always be given a colour in that way
  • I passed in an array into CNS_STATS.tpl, containing all the usergroup names and colours. I then processed the array using a Tempcode LOOP directive that spat out comma-separated links. I used a common GET/SET trick so I could show commas between each usergroup without also suffering a leading/trailing comma
Don't worry that I keep pulling out clever things from a proverbial hat. There are a finite number of standard Composr programming 'tricks' so once you know them all you can stop learning.


Gambling hook (line and sinker)

Create a new Point Store product based around gambling. Members pay a certain set price to do a gamble (e.g. 3 points), and then they receive a random number of points within a range (e.g. -20 to 20). Therefore the gambling odds are fair, but there's an 'administration fee' for it.

Point Store hooks are just files in sources/hooks/modules/pointstore or sources_custom/hooks/modules/pointstore. Use community_billboard.php as an example, but yours should be much simpler. There are actually only three functions (methods, technically) that need to be in a Point Store hook:
  1. init, which performs common initialisation for all steps (you can probably leave this empty)
  2. info, which contains Tempcoded HTML which describes the product and provides a link
  3. a function that has the same name as whatever type parameter you used in the aforementioned link; this either is the first in a chain of input and confirmation and delivery, or for a simple case like this, you can just do the actual gamble delivery in this single function.

Be imaginative

Make a feature change of your own design, and release it as an addon.

Points challenge

125 points will be given to any user that releases a working Composr addon (which may be based off pre-existing Open Source code, made by anybody) so long as the addon contains at least 1000 lines of PHP code.

To claim your prize, post in the Addons forum and 'report post' with the phrase '125 points please' in your report.

See also


Please rate this tutorial:

Have a suggestion? Report an issue on the tracker.

Back to Top