Fix "Unknown image format" error when showing or updating profiles

wx only decodes the image formats its own handlers support. Mastodon
instances commonly serve avatars and headers as WebP, so wx.Image failed
and raised a modal "Unknown image format" dialog, which blocked the user
from updating their profile or from reading someone's profile.

Images are now decoded with Pillow and their raw pixel data handed over
to wx, through a new mysc.image_utils module shared by the Mastodon and
Bluesky profile dialogs. Undecodable or unreachable images log the
problem and leave an empty placeholder instead of raising.

Also accept WebP and JPEG files in the header/avatar file pickers, warn
the user with a common message dialog when the picked file cannot be
read, and fix the parentless wx.StaticBitmap placeholders that were
created when a profile image could not be downloaded.
This commit is contained in:
Juanjo M
2026-08-02 15:08:25 +01:00
parent 130afe3432
commit b6681df03c
8 changed files with 241 additions and 42 deletions
+3
View File
@@ -64,3 +64,6 @@ def common_error(message):
"""Show a generic error dialog with the provided message."""
dlg = wx.MessageDialog(None, message, _("Error"), wx.OK | wx.ICON_ERROR)
return dlg.ShowModal()
def unsupported_image():
return wx.MessageDialog(None, _("TWBlue was unable to load the selected image. Please make sure the file is a valid image and try again."), _("Error"), wx.ICON_ERROR).ShowModal()
+8 -7
View File
@@ -4,10 +4,11 @@ import logging
import languageHandler
import builtins
import requests
from io import BytesIO
from threading import Thread
from pubsub import pub
from mysc.image_utils import load_scaled_image
_ = getattr(builtins, "_", lambda s: s)
logger = logging.getLogger(__name__)
@@ -233,14 +234,14 @@ class ShowUserProfileDialog(wx.Dialog):
"""Draws downloaded images on the bitmap controls."""
try:
if banner_bytes:
banner_image = wx.Image(BytesIO(banner_bytes), wx.BITMAP_TYPE_ANY)
banner_image.Rescale(300, 100, wx.IMAGE_QUALITY_HIGH)
self.bannerImage.SetBitmap(banner_image.ConvertToBitmap())
banner_image = load_scaled_image(banner_bytes, 300, 100)
if banner_image is not None:
self.bannerImage.SetBitmap(banner_image.ConvertToBitmap())
if avatar_bytes:
avatar_image = wx.Image(BytesIO(avatar_bytes), wx.BITMAP_TYPE_ANY)
avatar_image.Rescale(150, 150, wx.IMAGE_QUALITY_HIGH)
self.avatarImage.SetBitmap(avatar_image.ConvertToBitmap())
avatar_image = load_scaled_image(avatar_bytes, 150, 150)
if avatar_image is not None:
self.avatarImage.SetBitmap(avatar_image.ConvertToBitmap())
self.Layout()
self.Fit()
+7 -7
View File
@@ -1,7 +1,6 @@
# -*- coding: utf-8 -*-
"""Wx dialogs for showing a user's profile."""
from io import BytesIO
from pubsub import pub
from typing import Tuple
import requests
@@ -10,6 +9,7 @@ from logging import getLogger
from threading import Thread
from sessions.mastodon.utils import html_filter
from mysc.image_utils import load_scaled_image
log = getLogger(__name__)
@@ -229,11 +229,11 @@ class ShowUserProfile(wx.Dialog):
"""Draws images on the bitmap ui"""
# log.debug("Drawing images...")
# Header
headerImage = wx.Image(BytesIO(headerImageBytes), wx.BITMAP_TYPE_ANY)
headerImage.Rescale(300, 100, wx.IMAGE_QUALITY_HIGH)
self.headerImage.SetBitmap(headerImage.ConvertToBitmap())
headerImage = load_scaled_image(headerImageBytes, 300, 100)
if headerImage is not None:
self.headerImage.SetBitmap(headerImage.ConvertToBitmap())
# Avatar
avatarImage = wx.Image(BytesIO(avatarImageBytes), wx.BITMAP_TYPE_ANY)
avatarImage.Rescale(150, 150, wx.IMAGE_QUALITY_HIGH)
self.avatarImage.SetBitmap(avatarImage.ConvertToBitmap())
avatarImage = load_scaled_image(avatarImageBytes, 150, 150)
if avatarImage is not None:
self.avatarImage.SetBitmap(avatarImage.ConvertToBitmap())
+48 -28
View File
@@ -1,15 +1,43 @@
# -*- coding: utf-8 -*-
import os
import requests
from io import BytesIO
from logging import getLogger
from typing import Optional
import requests
import wx
from mysc.image_utils import load_scaled_image
from wxUI import commonMessageDialogs
log = getLogger("wxUI.dialogs.mastodon.updateProfile")
#: Formats accepted when the user picks a new header or avatar.
IMAGE_WILDCARD = "Images (*.png;*.jpg;*.jpeg;*.gif;*.webp)|*.png;*.jpg;*.jpeg;*.gif;*.webp"
def return_true():
return True
def download_scaled_image(url: str, width: int, height: int) -> Optional[wx.Image]:
""" Downloads an image and decodes it, scaled to the given size.
:param url: URL of the image to download.
:param width: Width, in pixels, of the resulting image.
:param height: Height, in pixels, of the resulting image.
:returns: The decoded wx.Image, or None if it could not be downloaded or decoded.
"""
if not url:
return None
try:
response = requests.get(url, timeout=10)
response.raise_for_status()
except requests.exceptions.RequestException:
log.exception("Unable to download image from %s", url)
return None
return load_scaled_image(response.content, width, height)
class UpdateProfileDialog(wx.Dialog):
"""
A dialog for user to update his / her profile details.
@@ -56,16 +84,12 @@ class UpdateProfileDialog(wx.Dialog):
# header
header_label = wx.StaticText(panel, label=_("Header"))
try:
response = requests.get(self.header)
except requests.exceptions.RequestException:
# Create empty image
self.header_image = wx.StaticBitmap()
header_bitmap = download_scaled_image(self.header, 300, 100)
if header_bitmap is None:
# The image is unavailable or in a format we cannot decode, so show an empty placeholder.
self.header_image = wx.StaticBitmap(panel)
else:
image_bytes = BytesIO(response.content)
image = wx.Image(image_bytes, wx.BITMAP_TYPE_ANY)
image.Rescale(300, 100, wx.IMAGE_QUALITY_HIGH)
self.header_image = wx.StaticBitmap(panel, bitmap=image.ConvertToBitmap())
self.header_image = wx.StaticBitmap(panel, bitmap=header_bitmap.ConvertToBitmap())
self.header_image.AcceptsFocusFromKeyboard = return_true
self.change_header = wx.Button(panel, label=_("Change &header"))
@@ -77,16 +101,12 @@ class UpdateProfileDialog(wx.Dialog):
# avatar
avatar_label = wx.StaticText(panel, label=_("Avatar"))
try:
response = requests.get(self.avatar)
except requests.exceptions.RequestException:
# Create empty image
self.avatar_image = wx.StaticBitmap()
avatar_bitmap = download_scaled_image(self.avatar, 150, 150)
if avatar_bitmap is None:
# The image is unavailable or in a format we cannot decode, so show an empty placeholder.
self.avatar_image = wx.StaticBitmap(panel)
else:
image_bytes = BytesIO(response.content)
image = wx.Image(image_bytes, wx.BITMAP_TYPE_ANY)
image.Rescale(150, 150, wx.IMAGE_QUALITY_HIGH)
self.avatar_image = wx.StaticBitmap(panel, bitmap=image.ConvertToBitmap())
self.avatar_image = wx.StaticBitmap(panel, bitmap=avatar_bitmap.ConvertToBitmap())
self.avatar_image.AcceptsFocusFromKeyboard = return_true
self.change_avatar = wx.Button(panel, label=_("Change &avatar"))
@@ -163,8 +183,7 @@ class UpdateProfileDialog(wx.Dialog):
def on_change_header(self, *args):
"""Display a dialog for the user to choose a picture and update the
appropriate attribute"""
wildcard = "Images (*.png;*.jpg;*.gif)|*.png;*.jpg;*.gif"
dlg = wx.FileDialog(self, _("Select header image - max 2MB"), wildcard=wildcard)
dlg = wx.FileDialog(self, _("Select header image - max 2MB"), wildcard=IMAGE_WILDCARD)
if dlg.ShowModal() == wx.CLOSE:
return
if os.path.getsize(dlg.GetPath()) > 2097152:
@@ -178,15 +197,15 @@ class UpdateProfileDialog(wx.Dialog):
return self.on_change_header() if result == wx.YES else None
self.header = dlg.GetPath()
image = wx.Image(self.header, wx.BITMAP_TYPE_ANY)
image.Rescale(150, 150, wx.IMAGE_QUALITY_HIGH)
image = load_scaled_image(self.header, 150, 150)
if image is None:
return commonMessageDialogs.unsupported_image()
self.header_image.SetBitmap(image.ConvertToBitmap())
def on_change_avatar(self, *args):
"""Display a dialog for the user to choose a picture and update the
appropriate attribute"""
wildcard = "Images (*.png;*.jpg;*.gif)|*.png;*.jpg;*.gif"
dlg = wx.FileDialog(self, _("Select avatar image - max 2MB"), wildcard=wildcard)
dlg = wx.FileDialog(self, _("Select avatar image - max 2MB"), wildcard=IMAGE_WILDCARD)
if dlg.ShowModal() == wx.CLOSE:
return
if os.path.getsize(dlg.GetPath()) > 2097152:
@@ -200,6 +219,7 @@ class UpdateProfileDialog(wx.Dialog):
return self.on_change_avatar() if result == wx.YES else None
self.avatar = dlg.GetPath()
image = wx.Image(self.avatar, wx.BITMAP_TYPE_ANY)
image.Rescale(150, 150, wx.IMAGE_QUALITY_HIGH)
image = load_scaled_image(self.avatar, 150, 150)
if image is None:
return commonMessageDialogs.unsupported_image()
self.avatar_image.SetBitmap(image.ConvertToBitmap())