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/Queue extension in Invision Community 5

A core/Queue extension is a resumable batch job. You register it in your app's data/extensions.json, hand it work with IPS\Task::queue(), and core writes one row into core_queue. The queue task (IPS\core\tasks\queue) then calls IPS\Task::runQueue() in a loop until it runs out of time, memory, or rows — each call picks one row, instantiates your class, and calls run() exactly once.

There are only three consumers in the whole suite, and it is worth knowing all three because they behave differently:

  • IPS\Task::queue() — calls preQueueData(), does duplicate-checking, inserts the row.
  • IPS\Task::runQueue() — calls run(), then postComplete(). Called from applications/core/tasks/queue.php and from the ACP "Run background processes now" screen (applications/core/modules/admin/system/background.php).
  • IPS\core\extensions\core\Dashboard\BackgroundQueue::getBlock() — calls getProgress(). This is the only caller of getProgress().

The contract

IPS\Extensions\QueueAbstract declares three abstract methods and one optional one.

abstract public function preQueueData( array $data ): ?array;
    // Called ONCE by Task::queue(), before the row is inserted.
    // Return the array to store as the row's `data` column (JSON-encoded).
    // Return NULL to abort — nothing is queued, postComplete( [], FALSE ) is called.
    // Throwing \OutOfRangeException is caught by Task::queue() and treated as NULL.

abstract public function run( array &$data, int $offset ): int;
    // Called once per cycle. $data is BY REFERENCE and is re-saved to the row
    // if json_encode( $data ) differs from what was read.
    // Return the new offset (int). It is written to core_queue.offset.
    // Throw \IPS\Task\Queue\OutOfRangeException to signal "finished".

abstract public function getProgress( array $data, int $offset ): array;
    // Called ONLY by the ACP Dashboard "Background Processes" block.
    // Must return [ 'text' => string, 'complete' => float|int|null ].
    // Throw \OutOfRangeException to have the row omitted from the block.

public function postComplete( array $data, bool $processed = TRUE ) : void
    // Optional. Called after the core_queue row is DELETED.
    // $data is NOT what you think it is — see the trap below.

public int $rebuild = REBUILD_NORMAL;
    // Batch size hint. Nothing in core reads this property; only your own
    // run() does. REBUILD_INTENSE=1, REBUILD_SLOW=50, REBUILD_NORMAL=250,
    // REBUILD_QUICK=500 (defined in init.php).

protected function getCountDataFromClass( string $classname ) : array
    // Helper. Returns [ 'count' => ..., 'realCount' => ... ] for an
    // ActiveRecord class. Throws GLOBAL \OutOfRangeException on failure.

Enqueue with:

IPS\Task::queue( string $app, string $key, mixed $data = NULL, int $priority = 5, mixed $checkForDuplicationKeys = NULL ) : void

$key is the key you used in extensions.json, not the class name. priority is a TINYINT UNSIGNED column commented "Values 1 to 5 are allowed, 1 being highest priority."

A minimal example

applications/myapp/data/extensions.json:

{
    "core": {
        "Queue": {
            "PurgeThings": "IPS\\myapp\\extensions\\core\\Queue\\PurgeThings"
        }
    }
}

applications/myapp/extensions/core/Queue/PurgeThings.php:

<?php

namespace IPS\myapp\extensions\core\Queue;

use IPS\Db;
use IPS\Extensions\QueueAbstract;
use IPS\Member;
use IPS\Task\Queue\OutOfRangeException as QueueOutOfRangeException;
use OutOfRangeException;
use function defined;
use const IPS\REBUILD_SLOW;

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

class PurgeThings extends QueueAbstract
{
    public int $rebuild = REBUILD_SLOW;

    public function preQueueData( array $data ): ?array
    {
        $data['count'] = (int) Db::i()->select( 'COUNT(*)', 'myapp_things' )->first();

        /* Nothing to do — do not create a row at all */
        if ( !$data['count'] )
        {
            return null;
        }

        return $data;
    }

    public function run( array &$data, int $offset ): int
    {
        $rows = iterator_to_array(
            Db::i()->select( '*', 'myapp_things', null, 'thing_id ASC', array( $offset, $this->rebuild ) )
        );

        if ( !count( $rows ) )
        {
            throw new QueueOutOfRangeException;
        }

        foreach ( $rows as $row )
        {
            /* ... do the work. Must tolerate being run twice on the same row. ... */
        }

        $offset += count( $rows );

        if ( $offset >= $data['count'] )
        {
            throw new QueueOutOfRangeException;
        }

        return $offset;
    }

    public function getProgress( array $data, int $offset ): array
    {
        return array(
            'text'     => Member::loggedIn()->language()->addToStack( 'myapp_purging_things' ),
            'complete' => $data['count'] ? round( 100 / $data['count'] * $offset, 2 ) : 100
        );
    }

    public function postComplete( array $data, bool $processed = TRUE ) : void
    {
        /* $data is the core_queue ROW, not your array. Decode it yourself. */
        if ( !isset( $data['data'] ) )
        {
            return;
        }

        $queueData = json_decode( $data['data'], TRUE );
        /* ... teardown ... */
    }
}

Queue it:

IPS\Task::queue( 'myapp', 'PurgeThings', array( 'reason' => 'manual' ), 4 );

"Undefined array key" or json_decode() errors inside postComplete()

The docblock on QueueAbstract::postComplete() says "@param array $data Data returned from preQueueData". That is wrong for the normal completion path. In Task::runQueue() the call is:

catch( Task\Queue\OutOfRangeException $e )
{
    Db::i()->delete( 'core_queue', array( 'id=?', $queueData['id'] ) );

    if ( isset( $class ) AND method_exists( $class, 'postComplete' ) )
    {
        $class->postComplete( $queueData, TRUE );
    }
}

$queueData is the raw core_queue row: id, data, offset, date, app, key, priority, plus a synthetic _originalOffset. Your array is the JSON string in $data['data']. Every core implementation that needs it does the decode by hand — IPS\core\extensions\core\Queue\InstallLanguage, PruneLargeTable, UpdateTaggedItems, ConvertOpenTags and IPS\nexus\extensions\core\Queue\DeletePaymentMethod all open with some form of $data = json_decode( $data['data'], TRUE );.

There is a second shape. When preQueueData() returns NULL, Task::queue() calls $class->postComplete( [], FALSE ) — an empty array, so $data['data'] does not exist at all. Core's own FileCleanup extension guards against exactly this:

public function postComplete( array $data, bool $processed = TRUE ) : void
{
    if( !isset( $data['data'] ) )
    {
        return;
    }

    $data = json_decode( $data['data'], TRUE );
    ...
}

Copy that guard. Note also that postComplete() runs after the row is deleted — RebuildAchievements carries the comment "The core_queue row is deleted before this is run" and counts remaining rows accordingly. And postComplete() is never called at all if the app is uninstalled: Application::delete() does a bare Db::i()->delete( 'core_queue', array( 'app=?', $this->directory ) ).

RuntimeException with an empty message, and the job never finishes

This is the big one. Two classes named OutOfRangeException are in play, and IPS\Task\Queue\OutOfRangeException extends \OutOfRangeException, so the catch order in Task::runQueue() decides everything:

/* This means the task is done */
catch( Task\Queue\OutOfRangeException $e )
{
    Db::i()->delete( 'core_queue', array( 'id=?', $queueData['id'] ) );
    ...
}
/* Catch any OORE in the task and transform the exception so that we can display an error */
catch( OutOfRangeException $e )
{
    Log::log( $e, 'queue_oore');

    throw new RuntimeException( $e->getMessage() );
}

Only IPS\Task\Queue\OutOfRangeException means "complete". A plain global \OutOfRangeException is treated as a fatal error: the row is not deleted, the offset is not updated, and a RuntimeException is thrown out of runQueue(). The next cycle picks the same row and does the same thing forever.

You almost never throw a global \OutOfRangeException deliberately — you inherit one. ActiveRecord::load() throws it when a record is missing, so a single deleted member or content item inside your run() loop poisons the whole queue. This is why IPS\core\extensions\core\Queue\Follow wraps its load and converts:

try
{
    $item = $classname::load( $data['item'] );
}
catch( OutOfRangeException $e )
{
    /* Item no longer exists, so we're done here. */
    throw new \IPS\Task\Queue\OutOfRangeException;
}

The error surfaces nowhere near where you caused it. IPS exceptions are usually constructed with no message, so $e->getMessage() is '' and you get a RuntimeException with a blank message and a stack trace rooted in IPS\Task::runQueue(), containing no reference to your app. The only record of the real exception is the Log::log( $e, 'queue_oore') line above it — look in the ACP System Logs (core_log) filtered to category queue_oore. That entry has the original class, file and line.

The safest habit: alias the import so the two can never be confused, as UpdateWidgetAreas does — use IPS\Task\Queue\OutOfRangeException as QueueOutOfRangeException; — and wrap any ::load() call in your run().

ACP notification "Locked Task: queue", and background processing stalls for 15 minutes

This is the downstream effect of the previous trap, and it is what an admin actually reports to you.

Task::run() sets running = 1 in core_tasks before calling execute(), and only clears it afterwards:

$output = $this->execute();

$this->running = 0;
$this->lock_count = 0;
...
$this->save();

If anything escapes execute(), those lines never run. Task::runAndLog() only catches IPS\Task\Exception, and queue.php's callback only catches UnderflowException, so a RuntimeException from your run() escapes both. The queue task stays locked until Task::queued() sees it is more than 15 minutes stale ($fifteenMinutesAgo = ( time() - 900 )), unlocks it, and increments lock_count. On the third unlock, Task::unlock() fires AdminNotification::send( 'core', 'ConfigurationError', "taskLock-{$this->id}" ), which renders using the language string dashboard_tasks_broken"Locked Task: %s", i.e. "Locked Task: queue".

It gets worse under the traditional (non-cron) task method. IPS\Dispatcher\Standard::__destruct() runs tasks on page loads inside:

try
{
    $task = Task::queued();
    if ( $task )
    {
        $task->runAndLog();
    }
}
catch( Exception $e ) { }

That is a completely silent swallow — no log row, no output. The only symptom is the locked-task notification and a queue that stops moving. If the site uses the real cron entry point (applications/core/interface/task/task.php) you at least get an uncaught_exception log row — but note that handler catches Exception, not Throwable, so a PHP Error (a TypeError, a call to an undefined method) escapes even that and appears as a raw fatal in cron output.

The ACP Dashboard is blank or 500s after installing my app

The Background Processes dashboard block iterates up to 100 core_queue rows from every enabled app and calls each one's getProgress():

$class = new $extensions[ $queueData['key'] ];
$rows[] = $class->getProgress( json_decode( $queueData['data'], TRUE ), $queueData['offset'] );
}
catch ( OutOfRangeException $e ) { }

Only OutOfRangeException is caught. Anything else — a TypeError, an IPS\Db\Exception, an undefined index promoted to an error, a Theme failure — propagates out of getProgress(), out of getBlock(), and into IPS\core\modules\admin\overview\dashboard::manage(), where $blocks[ $key ] = $extension->getBlock(); has no try/catch at all.

Your app's bug takes down the shared AdminCP Dashboard for every administrator, and the error names IPS\core\modules\admin\overview\dashboard, not your app. core_BackgroundQueue is one of the default main blocks for every admin, so this is not a corner case. Treat getProgress() as hostile-input code: it receives whatever JSON is in the row, including rows written by an older version of your app.

The idiom core uses is to throw a global \OutOfRangeException to bail out safely — note this is the opposite convention from run():

public function getProgress( array $data, int $offset ): array
{
    if ( !isset( $data['count'] ) )
    {
        throw new OutOfRangeException;   /* GLOBAL one — swallowed by the block */
    }
    ...
}

Progress bar is stuck at "indeterminate", or the block shows an undefined-key warning

The backgroundQueue template reads exactly two keys and nothing else:

{{if $row['complete'] === NULL OR $row['complete'] > 100}}
    ... indeterminate bar, {lang="progress_bar_percent_not_available"} ...
{{else}}
    <progress ... value="{expression="number_format( $row['complete'], 2)" }" max="100">
{{endif}}
...
<strong>{$row['text']|raw}</strong>

Omit complete and you get an undefined-array-key warning plus a permanently animated indeterminate bar — which reads as "stuck" to an admin. Return a value over 100 (easy if you use a count captured at queue time and rows were added since) and you get the same indeterminate bar. Core's RebuildPosts deliberately returns 'complete' => null for very large tables rather than a wrong number.

Also note {$row['text']|raw}: the text is printed unescaped. If you interpolate a user-supplied title into it, you have stored XSS in the AdminCP. Core passes user content through htmlsprintf and a template — see Follow::getProgress(), which builds its label with Theme::i()->getTemplate( 'global', 'core', 'global' )->basicUrl(...) rather than string concatenation.

Task::queue() throws InvalidArgumentException with no message

Task::queue() resolves your class through Application::extensions( 'core', 'Queue', FALSE ), which reads data/extensions.json and applies this filter:

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

A class that does not exist is silently dropped from the list. Then:

if ( !isset( $extensions[ $key ] ) )
{
    throw new InvalidArgumentException;
}

No message, no key name, no app name. The three causes, in order of likelihood: the key is missing from applications/<app>/data/extensions.json; the fully-qualified class name in the JSON does not match the file's namespace and class (remember the JSON needs doubled backslashes); or the file is not on disk in the built package. The same InvalidArgumentException is thrown if Application::load( $app ) fails, so a typo'd app directory looks identical.

Two further consequences of that construct=FALSE call. First, core does new $extensions[ $key ] with no constructor arguments — unlike most extension types, a Queue extension must be constructible with zero args. Second, if the extension key disappears while rows still exist (you renamed it, or shipped a version that removed the class), runQueue() hits:

if ( !isset( $extensions[ $queueData['key'] ] ) )
{
    throw new Task\Queue\OutOfRangeException;
}

…which lands in the "completed successfully" catch. The row is deleted and postComplete() is skipped — that is what the isset( $class ) guard in that catch block is for. Renaming a Queue key silently discards every pending job under the old name.

Task::queue() returns without queueing anything

Task::queue() has two silent no-op paths, both before the INSERT:

try
{
    $data = $class->preQueueData( $data );
}
catch( OutOfRangeException $e )
{
    $data = NULL;
}

if ( $data === NULL )
{
    $class->postComplete( [], FALSE );
    return;
}

Returning NULL from preQueueData() is the intended "there is nothing to do" signal and core uses it heavily (RebuildPosts and RebuildItems both return null when $data['count'] == 0; MemberContent returns NULL when the app is disabled or the class lacks the required feature).

The path that catches people is the catch. Any global \OutOfRangeException raised anywhere inside preQueueData() — including from the inherited helper getCountDataFromClass(), which converts any DB failure into throw new OutOfRangeException — is swallowed and turned into "nothing to queue". No log entry is written. If your job mysteriously never appears in core_queue, put a Log::debug() inside preQueueData() before assuming Task::queue() was never reached.

Also: preQueueData() is typed array $data, but Task::queue()'s third parameter defaults to NULL. Calling Task::queue( 'myapp', 'PurgeThings' ) with no data is a TypeError, not a no-op. Core always passes at least array() — e.g. Task::queue( 'core', 'RecountMemberContent', array(), 4 ).

Returning NULL from run() to signal completion no longer works

runQueue() still contains this branch:

if ( is_null( $newOffset ) )
{
    Log::log( $queueData['key'] . " returned a NULL offset - tasks should throw
        \\IPS\\Task\\Queue\\OutOfRangeException when they are finished", 'runQueue_log' );
    Db::i()->delete( 'core_queue', array( 'id=?', $queueData['id'] ) );
    ...
}

It is dead code in IC5. QueueAbstract::run() declares a return type of : int, and PHP return types are covariant — a subclass cannot widen it to ?int. Returning null produces Return value must be of type int, null returned from inside your own class, which then escapes as an uncaught TypeError (see the "Locked Task" trap above). Throw IPS\Task\Queue\OutOfRangeException; there is no other completion signal.

Related: the log category runQueue_log, which also carries the useful "returned a new offset of N" debug lines, is listed in Log::$excludeFromSettings. Those lines are written only when the \IPS\DEBUG_LOG constant is enabled in constants.php. If you are debugging a queue extension, turn it on first.

My changes to $data disappear when the job finishes

$data is passed by reference and core persists it — but only on the success branch:

$newOffset = $class->run( $json, $queueData['offset'] );
$queueData['offset'] = $newOffset;
...
else
{
    Db::i()->update( 'core_queue', array( 'offset' => $newOffset ), array( 'id=?', $queueData['id'] ) );

    $newData = json_encode( $json );

    /* Did it change?? */
    if ( $newData !== $queueData['data'] )
    {
        $queueData['data'] = $newData;
        Db::i()->update( 'core_queue', array( 'data' => $newData ), array( 'id=?', $queueData['id'] ) );
    }
}

That entire block is skipped when you throw IPS\Task\Queue\OutOfRangeException. Anything you wrote into $data during the final cycle is lost, and postComplete() receives the row as it was before that cycle. If postComplete() needs a result accumulated by run() — a list of failures, a count — write it during a cycle that returns an offset normally, and only throw on the following, empty cycle. IPS\nexus\extensions\core\Queue\DeletePaymentMethod relies on exactly this, reading $_data['failures'] in postComplete().

The comparison is a string comparison against the row's original JSON, so a change that round-trips to identical JSON is not re-saved — harmless, but it means you cannot use a no-op write to force a row touch.

A queued job restarts from offset 0 and its cleanup never runs

$checkForDuplicationKeys does not prevent a duplicate. It deletes the existing row:

if ( $got === count( $checkForDuplicationKeys ) )
{
    /* Ok, so we have a duplicate queue item, lets remove it so the new one which is set
       with the correct count is used and offset is returned to 0 to start over */
    Db::i()->delete( 'core_queue', array( 'id=?', $row['id'] ) );
}

It is a raw DELETE. The old job's progress is discarded no matter how far along it was, and — because this is not the runQueue() completion path — postComplete() is never called for the row that was thrown away. Any state preQueueData() set up for that job (a temp table, a flag setting, an uploaded file) that postComplete() was supposed to tear down is leaked. If your job allocates anything, do not use $checkForDuplicationKeys; query core_queue yourself and decide.

Three more things about the matching, all silent when they go wrong:

  • The guard is if ( is_array( $checkForDuplicationKeys ) and is_array( $data ) ). Passing a single key as a stringTask::queue( 'myapp', 'Foo', $data, 5, 'thing_id' ) — is ignored entirely, with no error. Always pass array( 'thing_id' ).
  • The keys are checked against $data after preQueueData() has run, and against the stored (already-preQueueData'd) JSON of existing rows. If preQueueData() renames or removes the key, dedup silently stops working.
  • A row matches only if every key is present on both sides and equal (loose ==). Core exploits this by inserting a marker key purely to enable the mechanism — applications/core/modules/admin/overview/files.php queues array( 'delete' => true, ... ) with the comment "We use a key in the data array just to trigger the code that deletes duplicate tasks".

Two jobs run out of order, or a job's work is done twice

runQueue() selects one row like this:

$queueData = Db::i()->select( '*', 'core_queue', array( Db::i()->in('app', $enabledApps ) ),
    'priority ASC, RAND()', 1, NULL, NULL, Db::SELECT_FROM_WRITE_SERVER )->first();

RAND(). Rows at the same priority are picked in random order every cycle, so two jobs of equal priority interleave arbitrarily and neither is guaranteed to finish first. The only ordering tool you have is the priority integer: all priority-1 rows drain before any priority-2 row runs. If job B depends on job A, either give A a lower number or queue B from A's postComplete() — which is what ConvertOpenTags::postComplete() does when it calls Task::queue( 'core', 'UpdateTaggedItems', ... ).

There is also no row-level lock on core_queue — the SELECT takes no lock and nothing marks the row as in-flight. The only serialisation is the lock on the queue row in core_tasks, and the ACP "Run background processes now" screen has to defend that lock by hand on every redirect cycle:

/* Make sure the task is locked */
$task = Task::load('queue', 'key');
$task->running = TRUE;
$task->next_run = time() + 900;
$task->save();

If an admin opens that screen while a cron run is in progress, or if a run times out mid-cycle and the row is retried at the same offset, your run() will process the same batch twice. Write run() to be idempotent. Note also Db::SELECT_FROM_WRITE_SERVER on the queue row itself — core does not trust a read replica here, and Follow::run() goes further and temporarily sets Db::i()->readWriteSeparation = FALSE around its work.

The job stops moving while my app is disabled

Both the runner and the dashboard filter by enabled applications — runQueue() with Db::i()->in( 'app', $enabledApps ), the dashboard block with a join on core_applications and app_enabled=1. Rows for a disabled app are invisible to both: they are not lost, but they neither run nor appear anywhere in the ACP.

If yours were the only rows left, the SELECT ... ->first() underflows, and queue.php reacts by turning the shared task off:

catch ( UnderflowException $e )
{
    $this->enabled = FALSE;
    $this->save();
    return FALSE;
}

Only two things switch it back on: Task::queue() (its last line is Db::i()->update( 'core_tasks', array( 'enabled' => 1 ), array( '`key`=?', 'queue' ) )) and re-enabling an application, which Application::set__enabled() handles with the comment "Enable queue task in case there are pending items". Uninstalling the app deletes its rows outright.

The "Run background processes now" screen shows a raw class name, not my translated text

getProgress() is not involved in that screen. applications/core/modules/admin/system/background.php builds its own label straight from the row:

$lang = array( $queueData['key'] );

if ( isset( $json['class'] ) )        { $lang[] = $json['class']; }
else if ( isset( $json['extension'] ) )        { $lang[] = $json['extension']; }
else if ( isset( $json['storageExtension'] ) ) { $lang[] = $json['storageExtension']; }

if ( isset( $json['count'] ) ) { $lang[] = " " . $offset . ' / ' . $json['count']; }

... addToStack( 'background_processes_processing', FALSE, array( 'sprintf' => array( implode( ' - ', $lang ) ) ) )

background_processes_processing is "Processing %s", and Lang applies sprintf replacements literally (they are htmlspecialchars'd, never translated). So the admin sees "Processing PurgeThings - IPS\myapp\Thing - 500 / 12000" — your extension key and a raw class name. The only way to influence it is to name your extension key readably and to put class and count keys in your data array. Your translated getProgress() text appears only on the ACP Dashboard block.

The percentage on that screen comes from $data['done'] + ( $queueData['offset'] - $queueData['_originalOffset'] ) against a total that background::getCount() computed by summing realCount, falling back to count, falling back to 1 per row. If your run() returns something that is not a monotonically increasing row offset — RebuildPosts returns $data['indexed'], a running total — the delta is still positive and the bar behaves. Return anything that can decrease and the bar goes backwards.

Verified against

Read from Invision Community 5.0.19 source (applications/core/data/versions.json long version 5001908): system/Extensions/QueueAbstract.php, system/Task/Task.php, system/Task/Queue/OutOfRangeException.php, system/Application/Application.php, system/Dispatcher/Standard.php, system/Lang/Lang.php, system/Log/Log.php, applications/core/tasks/queue.php, applications/core/modules/admin/system/background.php, applications/core/modules/admin/overview/dashboard.php, applications/core/extensions/core/Dashboard/BackgroundQueue.php, applications/core/dev/html/admin/dashboard/backgroundQueue.phtml, and all 97 core/Queue implementations shipped in applications/*/extensions/core/Queue/. Nothing here is inferred from IPS 4.x behaviour.


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.