Merge branch 'next-gen' of github.com:mcv-software/twblue into next-gen

This commit is contained in:
2026-08-12 09:01:05 -06:00
16 changed files with 312 additions and 72 deletions
+1
View File
@@ -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. * 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)) * 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 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 ## Changes in version 2026.01.13
+25 -25
View File
@@ -1,32 +1,32 @@
accessible_output2 @ git+https://github.com/accessibleapps/accessible_output2@57bda997d98e87dd78aa049e7021cf777871619b accessible_output2 @ git+https://github.com/accessibleapps/accessible_output2@57bda997d98e87dd78aa049e7021cf777871619b
arrow==1.4.0 arrow==1.4.0
attrs==25.4.0 attrs==26.1.0
backports.functools-lru-cache==2.0.0 backports.functools-lru-cache==2.0.0
blurhash==1.1.5 blurhash==1.1.5
certifi==2026.2.25 certifi==2026.7.22
chardet==6.0.0.post1 chardet==7.5.1
charset-normalizer==3.4.4 charset-normalizer==3.4.9
colorama==0.4.6 colorama==0.4.6
configobj==5.0.9 configobj==5.0.9
coverage==7.13.4 coverage==7.15.4
cx-Freeze==8.6.0 cx-Freeze==8.6.4
cx-Logging==3.2.1 cx-Logging==3.2.1
decorator==5.2.1 decorator==5.3.1
demoji==1.1.0 demoji==2.0.0
deepl==1.28.0 deepl==1.30.0
future==1.0.0 future==1.0.0
idna==3.11 idna==3.18
importlib-metadata==8.7.1 importlib-metadata==9.0.0
iniconfig==2.3.0 iniconfig==2.3.0
libloader @ git+https://github.com/accessibleapps/libloader@bc94811c095b2e57a036acd88660be9a33260267 libloader @ git+https://github.com/accessibleapps/libloader@bc94811c095b2e57a036acd88660be9a33260267
libretranslatepy==2.1.4 libretranslatepy==2.1.4
lief==0.15.1 lief==0.15.1
Markdown==3.10.2 Markdown==3.10.3
Mastodon.py==2.1.4 Mastodon.py==2.2.1
numpy==2.4.2 numpy==2.5.1
oauthlib==3.3.1 oauthlib==3.3.1
packaging==25.0 packaging==26.3
pillow==12.1.1 pillow==12.3.0
platform_utils @ git+https://github.com/accessibleapps/platform_utils@e0d79f7b399c4ea677a633d2dde9202350d62c38 platform_utils @ git+https://github.com/accessibleapps/platform_utils@e0d79f7b399c4ea677a633d2dde9202350d62c38
pluggy==1.6.0 pluggy==1.6.0
psutil==7.2.2 psutil==7.2.2
@@ -34,26 +34,26 @@ pyenchant==3.3.0
pypiwin32==223 pypiwin32==223
Pypubsub==4.0.7 Pypubsub==4.0.7
PySocks==1.7.1 PySocks==1.7.1
pytest==9.0.2 pytest==9.1.1
python-dateutil==2.9.0.post0 python-dateutil==2.9.0.post0
python-magic-bin==0.4.14 python-magic-bin==0.4.14
python-vlc==3.0.21203 python-vlc==3.0.21203
pywin32==311 pywin32==312
requests==2.32.5 requests==2.34.2
requests-oauthlib==2.0.0 requests-oauthlib==2.0.0
requests-toolbelt==1.0.0 requests-toolbelt==1.0.0
rfc3986==2.0.0 rfc3986==2.0.0
setuptools==82.0.0 setuptools==83.0.0
six==1.17.0 six==1.17.0
sniffio==1.3.1 sniffio==1.3.1
sound_lib @ git+https://github.com/accessibleapps/sound_lib@a439f0943fb95ee7b6ba24f51a686f47c4ad66b2 sound_lib @ git+https://github.com/accessibleapps/sound_lib@a439f0943fb95ee7b6ba24f51a686f47c4ad66b2
sqlitedict==2.1.0 sqlitedict==2.1.0
twitter-text-parser==3.0.0 twitter-text-parser==3.0.0
types-python-dateutil==2.9.0.20260302 types-python-dateutil==2.9.0.20260807
urllib3==2.6.3 urllib3==2.7.0
win-inet-pton==1.1.0 win-inet-pton==1.1.0
winpaths==0.2 winpaths==0.2
wxPython==4.2.5 wxPython==4.3.1
youtube-dl==2021.12.17 youtube-dl==2021.12.17
zipp==3.23.0 zipp==4.1.0
atproto>=0.0.65 atproto>=0.0.69
+1 -1
View File
@@ -44,7 +44,7 @@ braille_reporting = boolean(default=True)
speech_reporting = boolean(default=True) speech_reporting = boolean(default=True)
[templates] [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.") person = string(default="$display_name (@$screen_name). $followers followers, $following following, $posts posts. Joined $created_at.")
notification = string(default="$display_name $text, $date") notification = string(default="$display_name $text, $date")
+1 -1
View File
@@ -357,7 +357,7 @@ class Handler:
buffer.session.settings["templates"] = {} buffer.session.settings["templates"] = {}
templates_cfg = buffer.session.settings.get("templates", {}) templates_cfg = buffer.session.settings.get("templates", {})
template_state = { 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."), "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"), "notification": templates_cfg.get("notification", "$display_name $text, $date"),
} }
+2 -2
View File
@@ -804,12 +804,12 @@ class BaseBuffer(base.Buffer):
try: try:
if self.type == "notifications": if self.type == "notifications":
template = template_settings.get("notification", "$display_name $text, $date") 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) return templates.render_notification(item, template, post_template, self.session.settings, relative_times, offset_hours)
if self.type in ("user", "post_user_list"): 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.") 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) 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) return templates.render_post(item, template, self.session.settings, relative_times, offset_hours)
except Exception: except Exception:
# Fallback to compose if any template render fails. # Fallback to compose if any template render fails.
+77
View File
@@ -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
+9
View File
@@ -136,6 +136,11 @@ def compose_post(post, db, settings, relative_times, show_screen_names=False, sa
if images: if images:
text += f" [{len(images)} {_('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): if etype and ("recordWithMedia" in etype):
media = g(embed, "media", {}) media = g(embed, "media", {})
mtype = g(media, "$type") or g(media, "py_type") 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", []) images = g(media, "images", [])
if images: if images:
text += f" [{len(images)} {_('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: elif mtype and "external" in mtype:
ext = g(media, "external", {}) ext = g(media, "external", {})
title = g(ext, "title", "") title = g(ext, "title", "")
+12
View File
@@ -96,6 +96,18 @@ class Session(base.baseSession):
self.settings.write() self.settings.write()
except Exception: except Exception:
pass 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: except Exception:
log.exception("Failed to migrate legacy Blueski settings") log.exception("Failed to migrate legacy Blueski settings")
+4
View File
@@ -69,9 +69,13 @@ def _extract_image_descriptions(post, record):
mtype = _g(media, "$type") or _g(media, "py_type") or "" mtype = _g(media, "$type") or _g(media, "py_type") or ""
if "images" in mtype: if "images" in mtype:
return list(_g(media, "images", []) or []) return list(_g(media, "images", []) or [])
if "gallery" in mtype:
return list(_g(media, "items", []) or [])
return [] return []
if "images" in etype: if "images" in etype:
return list(_g(embed, "images", []) or []) return list(_g(embed, "images", []) or [])
if "gallery" in etype:
return list(_g(embed, "items", []) or [])
return [] return []
images = [] images = []
+17 -1
View File
@@ -78,7 +78,7 @@ def _extract_images_from_embed(embed):
for img in (img_list or []): for img in (img_list or []):
url = None url = None
# Try all possible URL field names # 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) val = g(img, key)
if val and isinstance(val, str) and val.startswith("http"): if val and isinstance(val, str) and val.startswith("http"):
url = val url = val
@@ -103,12 +103,18 @@ def _extract_images_from_embed(embed):
if "images" in etype.lower(): if "images" in etype.lower():
images.extend(extract_images(g(embed, "images", []))) 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 # Check in recordWithMedia wrapper
if "recordwithmedia" in etype.lower(): if "recordwithmedia" in etype.lower():
media = g(embed, "media", {}) media = g(embed, "media", {})
mtype = g(media, "$type") or g(media, "py_type") or "" mtype = g(media, "$type") or g(media, "py_type") or ""
if "images" in mtype.lower(): if "images" in mtype.lower():
images.extend(extract_images(g(media, "images", []))) images.extend(extract_images(g(media, "images", [])))
if "gallery" in mtype.lower():
images.extend(extract_images(g(media, "items", [])))
return images return images
@@ -136,6 +142,12 @@ def is_image(post):
if images and len(images) > 0: if images and len(images) > 0:
return True 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 # Check in recordWithMedia wrapper
if "recordwithmedia" in etype.lower(): if "recordwithmedia" in etype.lower():
media = g(embed, "media", {}) media = g(embed, "media", {})
@@ -144,6 +156,10 @@ def is_image(post):
images = g(media, "images", []) images = g(media, "images", [])
if images and len(images) > 0: if images and len(images) > 0:
return True return True
if "gallery" in mtype.lower():
items = g(media, "items", [])
if items and len(items) > 0:
return True
return False return False
View File
+97
View File
@@ -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
+3
View File
@@ -64,3 +64,6 @@ def common_error(message):
"""Show a generic error dialog with the provided message.""" """Show a generic error dialog with the provided message."""
dlg = wx.MessageDialog(None, message, _("Error"), wx.OK | wx.ICON_ERROR) dlg = wx.MessageDialog(None, message, _("Error"), wx.OK | wx.ICON_ERROR)
return dlg.ShowModal() 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()
+6 -5
View File
@@ -4,10 +4,11 @@ import logging
import languageHandler import languageHandler
import builtins import builtins
import requests import requests
from io import BytesIO
from threading import Thread from threading import Thread
from pubsub import pub from pubsub import pub
from mysc.image_utils import load_scaled_image
_ = getattr(builtins, "_", lambda s: s) _ = getattr(builtins, "_", lambda s: s)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -233,13 +234,13 @@ class ShowUserProfileDialog(wx.Dialog):
"""Draws downloaded images on the bitmap controls.""" """Draws downloaded images on the bitmap controls."""
try: try:
if banner_bytes: if banner_bytes:
banner_image = wx.Image(BytesIO(banner_bytes), wx.BITMAP_TYPE_ANY) banner_image = load_scaled_image(banner_bytes, 300, 100)
banner_image.Rescale(300, 100, wx.IMAGE_QUALITY_HIGH) if banner_image is not None:
self.bannerImage.SetBitmap(banner_image.ConvertToBitmap()) self.bannerImage.SetBitmap(banner_image.ConvertToBitmap())
if avatar_bytes: if avatar_bytes:
avatar_image = wx.Image(BytesIO(avatar_bytes), wx.BITMAP_TYPE_ANY) avatar_image = load_scaled_image(avatar_bytes, 150, 150)
avatar_image.Rescale(150, 150, wx.IMAGE_QUALITY_HIGH) if avatar_image is not None:
self.avatarImage.SetBitmap(avatar_image.ConvertToBitmap()) self.avatarImage.SetBitmap(avatar_image.ConvertToBitmap())
self.Layout() self.Layout()
+5 -5
View File
@@ -1,7 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""Wx dialogs for showing a user's profile.""" """Wx dialogs for showing a user's profile."""
from io import BytesIO
from pubsub import pub from pubsub import pub
from typing import Tuple from typing import Tuple
import requests import requests
@@ -10,6 +9,7 @@ from logging import getLogger
from threading import Thread from threading import Thread
from sessions.mastodon.utils import html_filter from sessions.mastodon.utils import html_filter
from mysc.image_utils import load_scaled_image
log = getLogger(__name__) log = getLogger(__name__)
@@ -229,11 +229,11 @@ class ShowUserProfile(wx.Dialog):
"""Draws images on the bitmap ui""" """Draws images on the bitmap ui"""
# log.debug("Drawing images...") # log.debug("Drawing images...")
# Header # Header
headerImage = wx.Image(BytesIO(headerImageBytes), wx.BITMAP_TYPE_ANY) headerImage = load_scaled_image(headerImageBytes, 300, 100)
headerImage.Rescale(300, 100, wx.IMAGE_QUALITY_HIGH) if headerImage is not None:
self.headerImage.SetBitmap(headerImage.ConvertToBitmap()) self.headerImage.SetBitmap(headerImage.ConvertToBitmap())
# Avatar # Avatar
avatarImage = wx.Image(BytesIO(avatarImageBytes), wx.BITMAP_TYPE_ANY) avatarImage = load_scaled_image(avatarImageBytes, 150, 150)
avatarImage.Rescale(150, 150, wx.IMAGE_QUALITY_HIGH) if avatarImage is not None:
self.avatarImage.SetBitmap(avatarImage.ConvertToBitmap()) self.avatarImage.SetBitmap(avatarImage.ConvertToBitmap())
+48 -28
View File
@@ -1,15 +1,43 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import os import os
import requests from logging import getLogger
from io import BytesIO from typing import Optional
import requests
import wx 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(): def return_true():
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): class UpdateProfileDialog(wx.Dialog):
""" """
A dialog for user to update his / her profile details. A dialog for user to update his / her profile details.
@@ -56,16 +84,12 @@ class UpdateProfileDialog(wx.Dialog):
# header # header
header_label = wx.StaticText(panel, label=_("Header")) header_label = wx.StaticText(panel, label=_("Header"))
try: header_bitmap = download_scaled_image(self.header, 300, 100)
response = requests.get(self.header) if header_bitmap is None:
except requests.exceptions.RequestException: # The image is unavailable or in a format we cannot decode, so show an empty placeholder.
# Create empty image self.header_image = wx.StaticBitmap(panel)
self.header_image = wx.StaticBitmap()
else: else:
image_bytes = BytesIO(response.content) self.header_image = wx.StaticBitmap(panel, bitmap=header_bitmap.ConvertToBitmap())
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.AcceptsFocusFromKeyboard = return_true self.header_image.AcceptsFocusFromKeyboard = return_true
self.change_header = wx.Button(panel, label=_("Change &header")) self.change_header = wx.Button(panel, label=_("Change &header"))
@@ -77,16 +101,12 @@ class UpdateProfileDialog(wx.Dialog):
# avatar # avatar
avatar_label = wx.StaticText(panel, label=_("Avatar")) avatar_label = wx.StaticText(panel, label=_("Avatar"))
try: avatar_bitmap = download_scaled_image(self.avatar, 150, 150)
response = requests.get(self.avatar) if avatar_bitmap is None:
except requests.exceptions.RequestException: # The image is unavailable or in a format we cannot decode, so show an empty placeholder.
# Create empty image self.avatar_image = wx.StaticBitmap(panel)
self.avatar_image = wx.StaticBitmap()
else: else:
image_bytes = BytesIO(response.content) self.avatar_image = wx.StaticBitmap(panel, bitmap=avatar_bitmap.ConvertToBitmap())
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.AcceptsFocusFromKeyboard = return_true self.avatar_image.AcceptsFocusFromKeyboard = return_true
self.change_avatar = wx.Button(panel, label=_("Change &avatar")) self.change_avatar = wx.Button(panel, label=_("Change &avatar"))
@@ -163,8 +183,7 @@ class UpdateProfileDialog(wx.Dialog):
def on_change_header(self, *args): def on_change_header(self, *args):
"""Display a dialog for the user to choose a picture and update the """Display a dialog for the user to choose a picture and update the
appropriate attribute""" appropriate attribute"""
wildcard = "Images (*.png;*.jpg;*.gif)|*.png;*.jpg;*.gif" dlg = wx.FileDialog(self, _("Select header image - max 2MB"), wildcard=IMAGE_WILDCARD)
dlg = wx.FileDialog(self, _("Select header image - max 2MB"), wildcard=wildcard)
if dlg.ShowModal() == wx.CLOSE: if dlg.ShowModal() == wx.CLOSE:
return return
if os.path.getsize(dlg.GetPath()) > 2097152: 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 return self.on_change_header() if result == wx.YES else None
self.header = dlg.GetPath() self.header = dlg.GetPath()
image = wx.Image(self.header, wx.BITMAP_TYPE_ANY) image = load_scaled_image(self.header, 150, 150)
image.Rescale(150, 150, wx.IMAGE_QUALITY_HIGH) if image is None:
return commonMessageDialogs.unsupported_image()
self.header_image.SetBitmap(image.ConvertToBitmap()) self.header_image.SetBitmap(image.ConvertToBitmap())
def on_change_avatar(self, *args): def on_change_avatar(self, *args):
"""Display a dialog for the user to choose a picture and update the """Display a dialog for the user to choose a picture and update the
appropriate attribute""" appropriate attribute"""
wildcard = "Images (*.png;*.jpg;*.gif)|*.png;*.jpg;*.gif" dlg = wx.FileDialog(self, _("Select avatar image - max 2MB"), wildcard=IMAGE_WILDCARD)
dlg = wx.FileDialog(self, _("Select avatar image - max 2MB"), wildcard=wildcard)
if dlg.ShowModal() == wx.CLOSE: if dlg.ShowModal() == wx.CLOSE:
return return
if os.path.getsize(dlg.GetPath()) > 2097152: 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 return self.on_change_avatar() if result == wx.YES else None
self.avatar = dlg.GetPath() self.avatar = dlg.GetPath()
image = wx.Image(self.avatar, wx.BITMAP_TYPE_ANY) image = load_scaled_image(self.avatar, 150, 150)
image.Rescale(150, 150, wx.IMAGE_QUALITY_HIGH) if image is None:
return commonMessageDialogs.unsupported_image()
self.avatar_image.SetBitmap(image.ConvertToBitmap()) self.avatar_image.SetBitmap(image.ConvertToBitmap())