diff --git a/doc/changelog.md b/doc/changelog.md index 52afdc65..a9996523 100644 --- a/doc/changelog.md +++ b/doc/changelog.md @@ -55,6 +55,7 @@ This version introduces comprehensive support for the AT Protocol (ATProto), ena * Fixed an issue where pinned posts could cause the retrieval of large amounts of old content during buffer updates or when loading more items. * Added support for sending quoted posts! You can now quote other users' posts from the context menu or the new Boost dialog. ([#860](https://github.com/mcv-software/twblue/issues/860)) * Fixed an issue where HTML entities were not decoded when editing a post. ([#893](https://github.com/mcv-software/twblue/issues/893)) + * Fixed an error that displayed an "Unknown image format" dialog when viewing someone's profile or updating your own, if the avatar or header was served in a format TWBlue could not read, such as WebP. ([#977](https://github.com/mcv-software/twblue/issues/977)) ## Changes in version 2026.01.13 diff --git a/requirements.txt b/requirements.txt index cc8b378c..203ea39b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,32 +1,32 @@ accessible_output2 @ git+https://github.com/accessibleapps/accessible_output2@57bda997d98e87dd78aa049e7021cf777871619b arrow==1.4.0 -attrs==25.4.0 +attrs==26.1.0 backports.functools-lru-cache==2.0.0 blurhash==1.1.5 -certifi==2026.2.25 -chardet==6.0.0.post1 -charset-normalizer==3.4.4 +certifi==2026.7.22 +chardet==7.5.1 +charset-normalizer==3.4.9 colorama==0.4.6 configobj==5.0.9 -coverage==7.13.4 -cx-Freeze==8.6.0 +coverage==7.15.4 +cx-Freeze==8.6.4 cx-Logging==3.2.1 -decorator==5.2.1 -demoji==1.1.0 -deepl==1.28.0 +decorator==5.3.1 +demoji==2.0.0 +deepl==1.30.0 future==1.0.0 -idna==3.11 -importlib-metadata==8.7.1 +idna==3.18 +importlib-metadata==9.0.0 iniconfig==2.3.0 libloader @ git+https://github.com/accessibleapps/libloader@bc94811c095b2e57a036acd88660be9a33260267 libretranslatepy==2.1.4 lief==0.15.1 -Markdown==3.10.2 -Mastodon.py==2.1.4 -numpy==2.4.2 +Markdown==3.10.3 +Mastodon.py==2.2.1 +numpy==2.5.1 oauthlib==3.3.1 -packaging==25.0 -pillow==12.1.1 +packaging==26.3 +pillow==12.3.0 platform_utils @ git+https://github.com/accessibleapps/platform_utils@e0d79f7b399c4ea677a633d2dde9202350d62c38 pluggy==1.6.0 psutil==7.2.2 @@ -34,26 +34,26 @@ pyenchant==3.3.0 pypiwin32==223 Pypubsub==4.0.7 PySocks==1.7.1 -pytest==9.0.2 +pytest==9.1.1 python-dateutil==2.9.0.post0 python-magic-bin==0.4.14 python-vlc==3.0.21203 -pywin32==311 -requests==2.32.5 +pywin32==312 +requests==2.34.2 requests-oauthlib==2.0.0 requests-toolbelt==1.0.0 rfc3986==2.0.0 -setuptools==82.0.0 +setuptools==83.0.0 six==1.17.0 sniffio==1.3.1 sound_lib @ git+https://github.com/accessibleapps/sound_lib@a439f0943fb95ee7b6ba24f51a686f47c4ad66b2 sqlitedict==2.1.0 twitter-text-parser==3.0.0 -types-python-dateutil==2.9.0.20260302 -urllib3==2.6.3 +types-python-dateutil==2.9.0.20260807 +urllib3==2.7.0 win-inet-pton==1.1.0 winpaths==0.2 -wxPython==4.2.5 +wxPython==4.3.1 youtube-dl==2021.12.17 -zipp==3.23.0 -atproto>=0.0.65 +zipp==4.1.0 +atproto>=0.0.69 diff --git a/src/blueski.defaults b/src/blueski.defaults index e90b775b..bc758d64 100644 --- a/src/blueski.defaults +++ b/src/blueski.defaults @@ -44,7 +44,7 @@ braille_reporting = boolean(default=True) speech_reporting = boolean(default=True) [templates] -post = string(default="$display_name, $reply_to$safe_text $date.") +post = string(default="$display_name, $reply_to$safe_text $image_descriptions $date.") person = string(default="$display_name (@$screen_name). $followers followers, $following following, $posts posts. Joined $created_at.") notification = string(default="$display_name $text, $date") diff --git a/src/controller/blueski/handler.py b/src/controller/blueski/handler.py index 342f2b8e..44eda881 100644 --- a/src/controller/blueski/handler.py +++ b/src/controller/blueski/handler.py @@ -357,7 +357,7 @@ class Handler: buffer.session.settings["templates"] = {} templates_cfg = buffer.session.settings.get("templates", {}) template_state = { - "post": templates_cfg.get("post", "$display_name, $reply_to$safe_text $date."), + "post": templates_cfg.get("post", "$display_name, $reply_to$safe_text $image_descriptions $date."), "person": templates_cfg.get("person", "$display_name (@$screen_name). $followers followers, $following following, $posts posts. Joined $created_at."), "notification": templates_cfg.get("notification", "$display_name $text, $date"), } diff --git a/src/controller/buffers/blueski/base.py b/src/controller/buffers/blueski/base.py index 24173bae..48c0ae26 100644 --- a/src/controller/buffers/blueski/base.py +++ b/src/controller/buffers/blueski/base.py @@ -804,12 +804,12 @@ class BaseBuffer(base.Buffer): try: if self.type == "notifications": template = template_settings.get("notification", "$display_name $text, $date") - post_template = template_settings.get("post", "$display_name, $reply_to$safe_text $date.") + post_template = template_settings.get("post", "$display_name, $reply_to$safe_text $image_descriptions $date.") return templates.render_notification(item, template, post_template, self.session.settings, relative_times, offset_hours) if self.type in ("user", "post_user_list"): template = template_settings.get("person", "$display_name (@$screen_name). $followers followers, $following following, $posts posts. Joined $created_at.") return templates.render_user(item, template, self.session.settings, relative_times, offset_hours) - template = template_settings.get("post", "$display_name, $reply_to$safe_text $date.") + template = template_settings.get("post", "$display_name, $reply_to$safe_text $image_descriptions $date.") return templates.render_post(item, template, self.session.settings, relative_times, offset_hours) except Exception: # Fallback to compose if any template render fails. diff --git a/src/mysc/image_utils.py b/src/mysc/image_utils.py new file mode 100644 index 00000000..9e172e5c --- /dev/null +++ b/src/mysc/image_utils.py @@ -0,0 +1,77 @@ +# -*- coding: utf-8 -*- +""" Helpers to turn arbitrary image data into wx.Image objects. + +wx only knows about the image formats its own handlers support. Fediverse +instances (and Bluesky) happily serve avatars and headers in formats such as +WebP, which makes wx.Image fail and pop up a modal "Unknown image format" +error dialog, blocking the user. Pillow understands many more formats, so we +decode everything with it and hand the raw pixel data over to wx. +""" +from io import BytesIO +from logging import getLogger +from typing import Optional, Tuple, Union + +import wx +from PIL import Image as PILImage + +log = getLogger("mysc.image_utils") + +ImageSource = Union[bytes, bytearray, str] + +#: Pillow modes that carry per pixel transparency. +_TRANSPARENT_MODES = ("RGBA", "LA", "PA") + + +def decode_image(source: ImageSource) -> Tuple[int, int, bytes, Optional[bytes]]: + """ Decodes an image into raw pixel data suitable for wx. + + :param source: Raw image bytes or a path to an image file. + :returns: A (width, height, rgb_data, alpha_data) tuple. alpha_data is None + when the image has no transparency. + :raises Exception: Whatever Pillow raises when the data cannot be decoded. + """ + if isinstance(source, (bytes, bytearray)): + source = BytesIO(bytes(source)) + with PILImage.open(source) as image: + image.load() + has_alpha = image.mode in _TRANSPARENT_MODES or (image.mode == "P" and "transparency" in image.info) + if has_alpha: + image = image.convert("RGBA") + return image.width, image.height, image.convert("RGB").tobytes(), image.getchannel("A").tobytes() + image = image.convert("RGB") + return image.width, image.height, image.tobytes(), None + + +def load_image(source: ImageSource) -> Optional[wx.Image]: + """ Builds a wx.Image from raw image bytes or a path to an image file. + + :param source: Raw image bytes or a path to an image file. + :returns: The decoded wx.Image, or None if the data could not be decoded. + Callers are expected to skip drawing when None is returned, instead of + letting wx show its own error dialog. + """ + try: + width, height, rgb_data, alpha_data = decode_image(source) + except Exception: + log.exception("Unable to decode image data.") + return None + image = wx.Image(width, height) + image.SetData(rgb_data) + if alpha_data is not None: + image.SetAlpha(alpha_data) + return image + + +def load_scaled_image(source: ImageSource, width: int, height: int) -> Optional[wx.Image]: + """ Same as load_image, but rescales the result to the given size. + + :param source: Raw image bytes or a path to an image file. + :param width: Width, in pixels, of the resulting image. + :param height: Height, in pixels, of the resulting image. + :returns: The rescaled wx.Image, or None if the data could not be decoded. + """ + image = load_image(source) + if image is None: + return None + image.Rescale(width, height, wx.IMAGE_QUALITY_HIGH) + return image diff --git a/src/sessions/blueski/compose.py b/src/sessions/blueski/compose.py index a87bf630..900da253 100644 --- a/src/sessions/blueski/compose.py +++ b/src/sessions/blueski/compose.py @@ -136,6 +136,11 @@ def compose_post(post, db, settings, relative_times, show_screen_names=False, sa if images: text += f" [{len(images)} {_('images')}]" + if etype and ("gallery" in etype): + items = g(embed, "items", []) + if items: + text += f" [{len(items)} {_('images')}]" + if etype and ("recordWithMedia" in etype): media = g(embed, "media", {}) mtype = g(media, "$type") or g(media, "py_type") @@ -143,6 +148,10 @@ def compose_post(post, db, settings, relative_times, show_screen_names=False, sa images = g(media, "images", []) if images: text += f" [{len(images)} {_('images')}]" + elif mtype and "gallery" in mtype: + items = g(media, "items", []) + if items: + text += f" [{len(items)} {_('images')}]" elif mtype and "external" in mtype: ext = g(media, "external", {}) title = g(ext, "title", "") diff --git a/src/sessions/blueski/session.py b/src/sessions/blueski/session.py index 8e5810fe..5a6ab56b 100644 --- a/src/sessions/blueski/session.py +++ b/src/sessions/blueski/session.py @@ -96,6 +96,18 @@ class Session(base.baseSession): self.settings.write() except Exception: pass + # Upgrade old default post templates so image descriptions are read. + old_post_templates = ( + "$display_name, $safe_text $date.", + "$display_name, $reply_to$safe_text $date.", + ) + templates_cfg = self.settings.get("templates") + if templates_cfg is not None and templates_cfg.get("post") in old_post_templates: + templates_cfg["post"] = "$display_name, $reply_to$safe_text $image_descriptions $date." + try: + self.settings.write() + except Exception: + pass except Exception: log.exception("Failed to migrate legacy Blueski settings") diff --git a/src/sessions/blueski/templates.py b/src/sessions/blueski/templates.py index 527fd3d5..793bf0fd 100644 --- a/src/sessions/blueski/templates.py +++ b/src/sessions/blueski/templates.py @@ -69,9 +69,13 @@ def _extract_image_descriptions(post, record): mtype = _g(media, "$type") or _g(media, "py_type") or "" if "images" in mtype: return list(_g(media, "images", []) or []) + if "gallery" in mtype: + return list(_g(media, "items", []) or []) return [] if "images" in etype: return list(_g(embed, "images", []) or []) + if "gallery" in etype: + return list(_g(embed, "items", []) or []) return [] images = [] diff --git a/src/sessions/blueski/utils.py b/src/sessions/blueski/utils.py index 8bd61682..1bbdda1f 100644 --- a/src/sessions/blueski/utils.py +++ b/src/sessions/blueski/utils.py @@ -78,7 +78,7 @@ def _extract_images_from_embed(embed): for img in (img_list or []): url = None # Try all possible URL field names - for key in ["fullsize", "thumb", "url", "uri", "src"]: + for key in ["fullsize", "thumb", "thumbnail", "url", "uri", "src"]: val = g(img, key) if val and isinstance(val, str) and val.startswith("http"): url = val @@ -103,12 +103,18 @@ def _extract_images_from_embed(embed): if "images" in etype.lower(): images.extend(extract_images(g(embed, "images", []))) + # Gallery embed (app.bsky.embed.gallery or app.bsky.embed.gallery#view) + if "gallery" in etype.lower(): + images.extend(extract_images(g(embed, "items", []))) + # Check in recordWithMedia wrapper if "recordwithmedia" in etype.lower(): media = g(embed, "media", {}) mtype = g(media, "$type") or g(media, "py_type") or "" if "images" in mtype.lower(): images.extend(extract_images(g(media, "images", []))) + if "gallery" in mtype.lower(): + images.extend(extract_images(g(media, "items", []))) return images @@ -136,6 +142,12 @@ def is_image(post): if images and len(images) > 0: return True + # Gallery embed + if "gallery" in etype.lower(): + items = g(embed, "items", []) + if items and len(items) > 0: + return True + # Check in recordWithMedia wrapper if "recordwithmedia" in etype.lower(): media = g(embed, "media", {}) @@ -144,6 +156,10 @@ def is_image(post): images = g(media, "images", []) if images and len(images) > 0: return True + if "gallery" in mtype.lower(): + items = g(media, "items", []) + if items and len(items) > 0: + return True return False diff --git a/src/test/mysc/__init__.py b/src/test/mysc/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/test/mysc/test_image_utils.py b/src/test/mysc/test_image_utils.py new file mode 100644 index 00000000..3e368bde --- /dev/null +++ b/src/test/mysc/test_image_utils.py @@ -0,0 +1,97 @@ +# -*- coding: utf-8 -*- +""" Tests for mysc.image_utils. + +These are regression tests for the "Unknown image format" modal error dialog +that wx raised when a Mastodon instance served profile pictures in a format +wx cannot decode by itself, such as WebP. +""" +from io import BytesIO + +import pytest +import wx +from PIL import Image as PILImage + +from mysc import image_utils + + +@pytest.fixture(scope="module") +def app(): + """ wx needs an application object before image objects can be created. """ + application = wx.App() + yield application + application.Destroy() + + +def make_image_bytes(image_format, mode="RGB", size=(64, 32), color=(10, 20, 30)): + """ Generates an in memory image in the requested format. """ + image = PILImage.new(mode, size, color) + buffer = BytesIO() + image.save(buffer, format=image_format) + return buffer.getvalue() + + +@pytest.mark.parametrize("image_format", ["PNG", "JPEG", "GIF", "WEBP", "BMP"]) +def test_decode_image_supports_common_formats(image_format): + """ All formats served by fediverse instances should decode, WebP included. """ + width, height, rgb_data, alpha_data = image_utils.decode_image(make_image_bytes(image_format)) + assert (width, height) == (64, 32) + assert len(rgb_data) == 64 * 32 * 3 + assert alpha_data is None + + +def test_decode_image_keeps_alpha_channel(): + """ Transparent images should keep their alpha channel separated for wx. """ + data = make_image_bytes("PNG", mode="RGBA", color=(10, 20, 30, 128)) + width, height, rgb_data, alpha_data = image_utils.decode_image(data) + assert len(rgb_data) == width * height * 3 + assert alpha_data is not None + assert len(alpha_data) == width * height + assert set(alpha_data) == {128} + + +def test_decode_image_accepts_a_path(tmp_path): + """ Images picked by the user are passed around as filesystem paths. """ + path = tmp_path / "avatar.webp" + path.write_bytes(make_image_bytes("WEBP")) + width, height, rgb_data, alpha_data = image_utils.decode_image(str(path)) + assert (width, height) == (64, 32) + assert len(rgb_data) == 64 * 32 * 3 + + +def test_decode_image_raises_on_invalid_data(): + with pytest.raises(Exception): + image_utils.decode_image(b"this is definitely not an image") + + +@pytest.mark.parametrize("image_format", ["PNG", "JPEG", "GIF", "WEBP"]) +def test_load_image_returns_a_wx_image(app, image_format): + image = image_utils.load_image(make_image_bytes(image_format)) + assert image is not None + assert image.IsOk() + assert (image.GetWidth(), image.GetHeight()) == (64, 32) + + +def test_load_image_sets_alpha_on_transparent_images(app): + image = image_utils.load_image(make_image_bytes("PNG", mode="RGBA", color=(10, 20, 30, 128))) + assert image is not None + assert image.HasAlpha() + + +def test_load_image_returns_none_on_invalid_data(app): + """ Broken or unsupported data must not raise, so no error dialog is shown. """ + assert image_utils.load_image(b"not an image at all") is None + + +def test_load_image_returns_none_on_empty_data(app): + """ Servers answering with an empty body should not break profile dialogs. """ + assert image_utils.load_image(b"") is None + + +def test_load_scaled_image_rescales(app): + image = image_utils.load_scaled_image(make_image_bytes("WEBP"), 150, 150) + assert image is not None + assert (image.GetWidth(), image.GetHeight()) == (150, 150) + + +def test_load_scaled_image_returns_none_on_invalid_data(app): + assert image_utils.load_scaled_image(b"nope", 150, 150) is None diff --git a/src/wxUI/commonMessageDialogs.py b/src/wxUI/commonMessageDialogs.py index ae6a0719..bf4709e4 100644 --- a/src/wxUI/commonMessageDialogs.py +++ b/src/wxUI/commonMessageDialogs.py @@ -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() diff --git a/src/wxUI/dialogs/blueski/showUserProfile.py b/src/wxUI/dialogs/blueski/showUserProfile.py index 59723015..798aca32 100644 --- a/src/wxUI/dialogs/blueski/showUserProfile.py +++ b/src/wxUI/dialogs/blueski/showUserProfile.py @@ -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() diff --git a/src/wxUI/dialogs/mastodon/showUserProfile.py b/src/wxUI/dialogs/mastodon/showUserProfile.py index b79887c8..d3f94825 100644 --- a/src/wxUI/dialogs/mastodon/showUserProfile.py +++ b/src/wxUI/dialogs/mastodon/showUserProfile.py @@ -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()) diff --git a/src/wxUI/dialogs/mastodon/updateProfile.py b/src/wxUI/dialogs/mastodon/updateProfile.py index 9bbd46ef..92e81763 100644 --- a/src/wxUI/dialogs/mastodon/updateProfile.py +++ b/src/wxUI/dialogs/mastodon/updateProfile.py @@ -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())