Skip to content
View in the app

A better way to browse. Learn more.

ernestdefoe.online

A full-screen app on your home screen with push notifications, badges and more.

To install this app on iOS and iPadOS
  1. Tap the Share icon in Safari
  2. Scroll the menu and tap Add to Home Screen.
  3. Tap Add in the top-right corner.
To install this app on Android
  1. Tap the 3-dot menu (⋮) in the top-right corner of the browser.
  2. Tap Add to Home screen or Install app.
  3. Confirm by tapping Install.
ernestdefoe.online

Extensions, themes & support for Flarum and Invision Community

Vibe coding for the community web. Report a bug, request a feature, or dig into the source — this is where the tools you use get built, in the open.

We do custom Bespoke Invision Community apps. If you have an idea for something you want then use the contact form to get in touch with us.
Knowledge base

Things that cost me a day, so they cost you none

Working notes from building Invision Community and Flarum applications. Mostly the failures that give no error at all — the ones where everything installs cleanly and quietly does the wrong thing.

92 articles

Invision Community 5

86 articles

Extensions and contracts

39

What each extension point is for, what it must declare, and what happens when it is wrong — which is usually nothing visible.

Languages and text

5

The string table, translation, and the places where text does not appear where you expected it to.

Theming, templates and forms

9

Theme hooks, CSS that survives both colour schemes, and building forms that do not throw on render.

Background work and scheduled tasks

5

The queue system, work that has to happen after the response, and jobs that finish without doing anything.

Data, settings and storage

11

The database layer, settings, tags, file storage, and backing up a live site.

AI features and expectations

5

What these features do, what they cost, and what buyers reasonably but wrongly assume they do.

Application structure and releases

11

The JSON files an application is made of, versioning and upgrade steps, and testing from the command line.

Realtime, chat and calls

1

WebSocket gateways, relays and the server-side pieces live features depend on — where "it works when I test it" and "it works for your members" are different claims.

Nothing matches that.

The core/MemberACPProfileTabs extension: AdminCP member view tabs in Invision Community 5

When an administrator opens a member in the AdminCP — app=core&module=members&controller=members&do=view&id=1 — they see a two-column page of blocks: basic information, groups, warnings, content statistics, and so on. The core/MemberACPProfileTabs extension adds a second (third, fourth) whole page of blocks to that screen, reached by a tab bar across the top. On a stock install with Commerce installed there are exactly two: "Member View" and "Customer View". Your extension is what puts a third tab there and decides which blocks fill its two columns.

This is the outer container only. The individual boxes are a separate extension point, core/MemberACPProfileBlocks, and a tab does nothing but name them.

The contract

The base class is not under system/Extensions/. Unlike almost every other IPS 5 extension point there is no MemberACPProfileTabsAbstract. The class you extend is IPS\core\MemberACPProfile\MainTab, which lives at applications/core/sources/MemberACPProfile/MainTab.php.

The class itself is declared abstract, so it cannot be instantiated directly — but no method in it is abstract. Every method has a working default, so a subclass with an empty body compiles and runs; it just renders an empty tab. These are the real signatures, from MainTab.php:

abstract class MainTab
{
    /* Line 35. The member being viewed. Set by the constructor. */
    protected ?Member $member = null;

    /* Line 43. Takes the member, NOT the "checkAccess" argument that most
       other IPS extension constructors receive. See "who calls it" below. */
    public function __construct( Member $member )

    /* Line 53. Whether this admin may see the tab at all. Default TRUE.
       Returning FALSE removes the tab from the bar entirely. */
    public function canView() : bool

    /* Line 64. STATIC. Called on the class name from the template, never on
       an instance, so it must not touch $this->member. The default derives a
       language key from the namespace - see below. */
    public static function title() : string

    /* Line 76. Array of fully-qualified MemberACPProfileBlocks class-name
       STRINGS for the narrow left column. Default: empty array. */
    public function leftColumnBlocks() : array

    /* Line 86. Same, for the wide main column. Default: empty array. */
    public function mainColumnBlocks() : array

    /* Line 96. Builds the tab's HTML. You rarely override this outright;
       override it and call parent::output() if you need to push CSS or JS. */
    public function output() : string
}

The two block methods return strings, not objects. MainTab::output() instantiates each one itself at lines 100–111 with new $class( $this->member ).

Where the tab's label comes from

MainTab::title() at lines 64–69 does not ask you for a title. It splits its own class name on backslashes and builds a language key from parts 1 and 5:

$class = get_called_class();
$exploded = explode( '\\', $class );
return Member::loggedIn()->language()->addToStack( 'memberACPProfileTitle_' . $exploded[1] . '_' . $exploded[5] );

For IPS\nexus\extensions\core\MemberACPProfileTabs\Main that is memberACPProfileTitle_nexus_Main, which Commerce defines as "Customer View" in applications/nexus/dev/lang.php line 1045. Core's own is memberACPProfileTitle_core_Main — "Member View" — in applications/core/dev/lang/members.php line 380.

The Developer Center knows about this key. applications/core/modules/admin/developer/details.php line 576 maps the extension type to the pattern memberACPProfileTitle_{app}_{key} and reports it under missing admin language strings if you have not defined it.

Registration

Two files. The class at applications/myapp/extensions/core/MemberACPProfileTabs/MyTab.php, and an entry in applications/myapp/data/extensions.json:

{
    "core": {
        "MemberACPProfileTabs": {
            "MyTab": "IPS\\myapp\\extensions\\core\\MemberACPProfileTabs\\MyTab"
        }
    }
}

The key core uses internally is {appDirectory}_{ExtensionName} — built in Application::allExtensions() at system/Application/Application.php line 419 as $application->directory . '_' . $key. So the example above is myapp_MyTab, and that string is what appears in the &tab= query parameter. It must match the $exploded[1] . '_' . $exploded[5] that title() computes, which it will as long as you follow the standard namespace.

The Developer Center will write both the file and the JSON entry for you; the skeleton it copies is applications/core/data/defaults/extensions/MemberACPProfileTabs.txt.

Who calls it

Exactly one place in the whole suite: IPS\core\modules\admin\members\members::view(), at applications/core/modules/admin/members/members.php lines 1349–1363.

/* Get the available tabs */
$extensions = array();
foreach( Application::allExtensions( 'core', 'MemberACPProfileTabs', TRUE, 'core', 'Main', FALSE ) AS $key => $ext )
{
    $class = new $ext( $member );
    if ( $class->canView() )
    {
        $extensions[ $key ] = $ext;
    }
}

/* What's our active tab? */
$activeTab = ( isset( Request::i()->tab ) and array_key_exists( Request::i()->tab, $extensions ) ) ? Request::i()->tab : 'core_Main';
$classname = $extensions[ $activeTab ];
$tab = new $classname( $member );

Three details in that call matter.

  • The last argument is FALSE, meaning $construct=FALSE. allExtensions() returns class-name strings, and the controller constructs them itself. Application::constructExtensionClass() — which wraps construction in catch( RuntimeException | OutOfRangeException ) at Application.php lines 477–497 — is never reached. The usual safety net does not apply to this extension point.
  • The fourth and fifth arguments, 'core' and 'Main', sort core_Main to the front. "Member View" is always the first tab; everyone else appends in application order.
  • The third argument, TRUE, is an application-level access check (Application::canAccess(), Application.php line 5299), not a per-extension one. An admin with the core / applications / app_manage restriction passes it unconditionally.

Every registered tab class is constructed on every member view, whether or not it is the tab being displayed, because canView() is an instance method. The active tab is constructed twice — once in the loop, once at line 1363. Keep constructors cheap.

The list is then handed to applications/core/dev/html/admin/memberprofile/mainTemplate.phtml, which renders the bar. Note line 4:

{{if \count( $extensions ) > 1}}

With only one tab there is no tab bar at all. Line 9 calls {$classname::title()} statically on the stored string.

Tab switching is AJAX. dev/js/framework/common/ui/ips.ui.tabbar.js fetches the tab's href and injects the response into the panel; members::view() line 1385 returns bare $tab->output() for that request.

A complete working example

Commerce's tab is the only non-core implementation in the suite and is a good template. This is applications/nexus/extensions/core/MemberACPProfileTabs/Main.php in full, abridged only in the block lists:

<?php

namespace IPS\nexus\extensions\core\MemberACPProfileTabs;

use IPS\core\MemberACPProfile\MainTab;
use IPS\Member;
use IPS\nexus\Customer;
use IPS\Output;
use IPS\Theme;
use function defined;

if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
    header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' );
    exit;
}

class Main extends MainTab
{
    public function __construct( Member $member )
    {
        /* Swap in a richer object. Note: parent::__construct() is NOT called. */
        $this->member = Customer::load( $member->member_id );
    }

    public function canView(): bool
    {
        return (bool) Member::loggedIn()->hasAcpRestriction( 'nexus', 'customers', 'customers_view' );
    }

    public function leftColumnBlocks(): array
    {
        return array(
            'IPS\nexus\extensions\core\MemberACPProfileBlocks\AccountInformation',
        );
    }

    public function mainColumnBlocks(): array
    {
        $return = array();

        if ( Member::loggedIn()->hasAcpRestriction( 'nexus', 'customers', 'customers_view_statistics' ) )
        {
            $return[] = 'IPS\nexus\extensions\core\MemberACPProfileBlocks\Statistics';
        }

        $return[] = 'IPS\nexus\extensions\core\MemberACPProfileBlocks\ParentAccounts';

        /* ...further blocks, each behind its own hasAcpRestriction() check... */

        return $return;
    }

    public function output(): string
    {
        Output::i()->cssFiles = array_merge( Output::i()->cssFiles, Theme::i()->css( 'customer.css', 'nexus', 'admin' ) );
        Output::i()->jsFiles  = array_merge( Output::i()->jsFiles, Output::i()->js( 'admin_customer.js', 'nexus', 'admin' ) );

        return parent::output();
    }
}

Plus 'memberACPProfileTitle_nexus_Main' => "Customer View", in applications/nexus/dev/lang.php, and the extensions.json entry.

Core's own tab, applications/core/extensions/core/MemberACPProfileTabs/Main.php, is simpler still: it overrides nothing but the two block methods, and gates individual blocks on ACP restrictions (membertools_ip, member_mfa) rather than gating the tab.

Failure: the tab file exists but no tab appears, and nothing is logged

This is the common one and it is completely silent. Application::extensions() (Application.php lines 911–974) does not scan the extensions/ directory — that was the IPS 4 behaviour. It reads data/extensions.json and nothing else, and it skips any entry whose class fails class_exists() (lines 926–930):

if( !is_string( $classname ) or !class_exists( $classname ) )
{
    /* Switching between branches confuses extensions */
    continue;
}

A typo in the namespace, a missing JSON entry, or a file whose declared class name does not match the fully-qualified name in the JSON all produce the same result: no tab, no error, no system log entry. (A JSON key that differs from the class basename is a different bug — the tab still appears, but title() computes a language key that nobody defined; see the next section.) The resolved list is then cached in the datastore as Store::i()->extensions keyed by extension type (Application.php line 442), and rebuilt only when the MemberACPProfileTabs key is absent — so fixing the file on a live site does nothing until the cache is cleared. Application::extensions() also holds a per-request static, static::$_loadedExtensions.

The same cache carries the recovery-mode trap that affects every extension type: Application.php lines 395–399 skip third-party apps when RECOVERY_MODE is on, and the filtered result is still written to the datastore. One member view loaded in recovery mode bakes a core-and-nexus-only tab list in until the cache is cleared.

Failure: the tab label renders as "memberACPProfileTitle_myapp_MyTab"

You did not define the language string, or you defined it with the wrong key. IPS renders unresolved keys as their literal text, so the tab bar shows the raw key. Add it to applications/myapp/dev/lang.php and rebuild; the Developer Center's language-string scan lists it for you (details.php line 576).

A subtler variant: title() hard-codes array offsets 1 and 5 of the exploded class name. It assumes exactly IPS\{app}\extensions\core\MemberACPProfileTabs\{Name}. Deviate and you get one of two failures. A shorter namespace — a shared base class in sources/ registered directly, say IPS\myapp\sources\Profile\MyTab — leaves $exploded[5] undefined; PHP 8 raises an "Undefined array key 5" warning and the key degrades to memberACPProfileTitle_{app}_. A deeper one — a subdirectory, IPS\myapp\extensions\core\MemberACPProfileTabs\Sub\MyTab — is worse, because $exploded[5] is defined but wrong: it is Sub, so the key silently becomes memberACPProfileTitle_myapp_Sub with no warning at all. Override title() as a public static function if you must deviate.

Failure: the whole member view dies with an uncaught error

Because members::view() constructs the classes directly rather than through constructExtensionClass(), there is no try/catch anywhere in lines 1351–1363. Consequences:

  • If your constructor throws — an OutOfRangeException from a ::load() on a record that does not exist, say — the exception escapes and the AdminCP member view breaks for every member, not just yours. Commerce's constructor calls Customer::load() unguarded; it gets away with it because view() already 404s on a missing member at line 1344.
  • If your class does not extend MainTab (or extends nothing), $class->canView() at line 1354 is a fatal Error: Call to undefined method.
  • If your override of canView() declares a return type incompatible with the parent's : bool, that is a fatal at class-compile time, raised by the autoloader inside class_exists() — which means it fires wherever the extension list is built, not only on the member view.

Note the asymmetry: a broken tab class breaks the page, whereas a broken registration is silent. If the member view suddenly white-screens after you install an app, the tab extension is the first thing to look at.

Failure: the tab appears but is blank, or a block is missing

An empty tab means both leftColumnBlocks() and mainColumnBlocks() returned empty arrays — which is the inherited default, so this is what you get if you forgot to override them. tabTemplate.phtml renders an empty <section> without complaint.

A wrong class-name string is not silent. MainTab::output() lines 100–111 do new $class( $this->member ) with no class_exists() guard, so a typo in a block class name is a fatal "Class not found". Note also that the strings are literal — the blocks are constructed by name, so a block that lives in another application works fine as long as that application is installed. It is not checked, so listing a block from an app the site does not have installed is a fatal error.

Individual blocks can also opt in to your tab from the outside. MainTab::output() lines 118–142 walk every registered MemberACPProfileBlocks extension, skip any your two block methods already named (lines 121–124 keep a $seenBlocks list, so a block cannot be added twice), and inject any of the rest whose static $displayTab equals {app}_{ExtensionName} of the current tab:

if( isset( $ext::$displayTab ) AND $ext::$displayTab == $thisTab )
{
    $class = new $ext( $this->member );
    if( isset( $ext::$displayColumn ) )
    {
        switch( $ext::$displayColumn )
        {
            case 'left':  $leftColumnBlocks[] = $class; break;
            case 'main':  $mainColumnBlocks[] = $class; break;
        }
    }
}

There is a quiet trap here for the block author, worth knowing because you will be asked about it: a block that sets $displayTab but leaves $displayColumn at the skeleton's default empty string matches neither case. The block is constructed and then thrown away, with no output and no error. The skeleton at applications/core/data/defaults/extensions/MemberACPProfileBlocks.txt lines 35 and 44 ships both properties defaulted to ''. No first-party block in 5.0.19 uses this mechanism; it exists purely for third-party blocks.

Failure: your CSS is missing after switching tabs

Commerce adds its stylesheet by overriding output() and pushing onto Output::i()->cssFiles before calling parent::output(). That works on a full page load, where the head is rendered after the tab body is built. It does not help on the AJAX tab switch: ips.ui.tabbar.js requests the tab URL and injects the returned fragment into the existing page, and members::view() line 1385 returns only $tab->output() — the head of the already-rendered page is not revisited, so a stylesheet registered during that request has nowhere to go. Loading the tab's URL directly (with &tab=myapp_MyTab) is a full render and does pick it up. If your tab needs styling under all entry paths, ship it in the ACP's global CSS rather than registering it from output(). The precise behaviour of an AJAX-only tab switch on a first paint has not been reproduced against a running site; this is read from the controller and the tab-bar JavaScript.

Failure: a bookmarked tab URL silently lands on Member View

Line 1361 falls back to 'core_Main' whenever Request::i()->tab is not a key in the filtered list. An admin whose canView() check fails, or who has a link to a tab from an app that has since been disabled, is not told anything — they simply see the default tab. There is no error and no redirect, so the URL in the address bar still says &tab=myapp_MyTab.

Related but different extension points

  • core/MemberACPProfileBlocks — the boxes inside a tab. Base class IPS\core\MemberACPProfile\Block, with LazyLoadingBlock and TabbedBlock subclasses in the same directory. This is what you usually want.
  • core/MemberACPProfileContentTab — a tab inside the Content Statistics block, not a page-level tab. Consumed by applications/core/extensions/core/MemberACPProfileBlocks/ContentStatistics.php line 171. Downloads and Gallery implement it.
  • core/MemberACPManagement — the member list, not the member view. This one does have an abstract at system/Extensions/MemberACPManagementAbstract.php.
  • core/MemberRestrictions — extends the Warnings & Restrictions block's form. Base class IPS\core\MemberACPProfile\Restriction.

Verified against

Read from Invision Community 5.0.19 source (applications/core/data/versions.json, whose highest — and last — entry is 5001908 => "5.0.19"; the file is ordered oldest-first). Files inspected: applications/core/sources/MemberACPProfile/MainTab.php, Block.php, LazyLoadingBlock.php, TabbedBlock.php and Restriction.php; both implementations that ship in the suite, applications/core/extensions/core/MemberACPProfileTabs/Main.php and applications/nexus/extensions/core/MemberACPProfileTabs/Main.php; the sole consumer applications/core/modules/admin/members/members.php; Application::allExtensions(), Application::extensions(), Application::constructExtensionClass() and Application::canAccess() in system/Application/Application.php; applications/core/modules/admin/developer/details.php; the templates applications/core/dev/html/admin/memberprofile/mainTemplate.phtml and tabTemplate.phtml; the skeletons applications/core/data/defaults/extensions/MemberACPProfileTabs.txt and MemberACPProfileBlocks.txt; and dev/js/framework/common/ui/ips.ui.tabbar.js. A grep of the full tree for MemberACPProfileTabs finds implementations in core and nexus only — forums, cms, downloads, gallery, blog and calendar ship none — and exactly one consumer.


User Feedback

Recommended Comments

There are no comments to display.

Account

Navigation

Search

Search

Configure browser push notifications

Chrome (Android)
  1. Tap the lock icon next to the address bar.
  2. Tap Permissions → Notifications.
  3. Adjust your preference.
Chrome (Desktop)
  1. Click the padlock icon in the address bar.
  2. Select Site settings.
  3. Find Notifications and adjust your preference.