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.

A shared image vanishes the moment one member changes their profile photo

Your application offers members a set of images to use as their profile photo — avatars you supply, badges, team crests, anything shared. It works. Then one member picks a different one, and the image disappears for everyone else who chose it. No error, no log entry; just a broken avatar on other people's posts, discovered days later.

This is not a bug in your code so much as a consequence of how Invision Community stores profile photos, and it is easy to walk into because the obvious implementation is the one that breaks.

Why it happens

A member's photo is two columns on core_members: pp_photo_type and pp_main_photo. The obvious thing to do is set the type to custom — that is what an uploaded photo uses — and point pp_main_photo at your file.

The problem is Member::deletePhoto(), which runs whenever a photo is replaced. It deletes the underlying file when the outgoing type is custom, letter, or begins sync-. That is correct for an uploaded photo, which belongs to exactly one member. It is destructive for an image ten members are sharing.

The failure is invisible until an image is popular. With one member wearing each image nothing goes wrong, so it survives testing, a staging site, and often the first weeks of real use. It only appears once two people have chosen the same picture — which is the entire point of offering a shared set.

The supported way

Core provides for this explicitly, and documents it in a comment in Member::photoUrl():

Other - This allows an app (such as Gallery) to set the pp_photo_type to a storage container to support custom images without duplicating them

So set pp_photo_type to your file storage container key rather than to custom. Core resolves the photo with File::get( pp_photo_type, pp_main_photo ) for any type containing an underscore, and the image then appears everywhere a profile photo appears — posts, profiles, hovercards, the member list, notification emails — with no theme hook of any kind.

The container key is <app>_<FileStorage extension class>. An app myapp with extensions/core/FileStorage/Photos.php gives myapp_Photos:

/* Order matters — see below. */
$member->pp_photo_type  = 'myapp_Photos';
$member->pp_main_photo  = $sharedFilePath;
$member->pp_thumb_photo = NULL;
$member->photo_last_update = time();
$member->save();

Because the outgoing type is now your container rather than custom, deletePhoto() leaves the file alone, and one copy serves everyone who chose it.

Set the type before the photo

This looks like a style preference and is not. set_pp_photo_type() records the previous type, and set_pp_main_photo() then consults that record to decide whether the outgoing file should be deleted. Core notes it in a comment on deletePhoto(): "It is common to update pp_photo_type before pp_main_photo."

Assign them the other way round and a member switching from their own uploaded photo to one of yours leaves their upload orphaned in storage forever. Nothing breaks visibly; storage just grows.

The FileStorage extension is not optional here

Normally a FileStorage extension is housekeeping — it lets an administrator move your files to S3 and lets IPS account for them. When the container key is the photo type, it becomes load-bearing: remove or rename the extension and every member wearing one of your images loses their avatar, because File::get() can no longer resolve the container.

Two things follow. Assert the key rather than trusting yourself to keep two names in sync:

/* CONTAINER must equal <app>_<extension class> */
assert( MyApp::CONTAINER === 'myapp_Photos' );

And implement move() properly. Moving a file to another storage method rewrites your own table, but members store the path, not your row id — so the same pass has to update them:

Db::i()->update( 'core_members',
    array( 'pp_main_photo' => $newPath ),
    array( 'pp_photo_type=? AND pp_main_photo=?', 'myapp_Photos', $oldPath )
);

Miss that and the storage move reports success while every affected member gets a broken image.

Deleting one of your images

Release the members first, then remove the file:

Db::i()->update( 'core_members',
    array( 'pp_photo_type' => NULL, 'pp_main_photo' => NULL, 'pp_thumb_photo' => NULL ),
    array( 'pp_photo_type=? AND pp_main_photo=?', 'myapp_Photos', $file )
);

In that order, a failure part-way leaves members with no photo, which falls back to their letter avatar. In the other order it leaves them pointing at storage that no longer exists, which renders as a broken image. Tell the administrator how many members a deletion will affect before they confirm it.

Deleting rows in SQL is not the same as deleting through your own code. A quick DELETE FROM on your table skips whatever cleanup that path does, leaving orphaned files in storage and, worse, members still pointing at them. Reach for the application's own delete path even when a query would be faster.

Counting who is wearing what

Resist keeping your own tally. A member can change their photo through core's own form at any time without telling your application, so a stored count drifts and quietly becomes fiction. Read it from the source instead:

SELECT pp_main_photo, COUNT(*) FROM core_members
WHERE pp_photo_type = 'myapp_Photos' GROUP BY pp_main_photo

One grouped query answers it for a whole screen, and it cannot be wrong.

Related

The pattern worth taking away: when core's own comments describe a mechanism — as photoUrl() does here — that is usually the supported path, and the obvious alternative is obvious because it is what a single-owner file would do. Ask who else might be pointing at the thing you are about to modify.


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.