mirror of
https://github.com/MCV-Software/TWBlue.git
synced 2026-08-17 18:38:11 +02:00
Merge branch 'next-gen' of github.com:mcv-software/twblue into next-gen
This commit is contained in:
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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"),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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", "")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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 = []
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
Reference in New Issue
Block a user