Compare commits

...

21 Commits

Author SHA1 Message Date
72809d024e merge is almost retarded but fails to be cute 2024-04-12 20:52:41 +10:00
gridtime
59b928d4f7 adds emoji presentation selector 2024-03-27 17:01:33 +01:00
Maze
12902d126c Add solution for missing libimagequant.so.0 2024-03-17 12:28:02 +00:00
Stefan Hansson
c096e239e7 Allow pymediainfo 6.x
Seems to work okay.
2024-03-08 14:19:50 +01:00
Stefan Hansson
abbe00d70f Allow lxml 5.x
Seems to work okay.
2024-03-08 14:18:43 +01:00
Maze
e3ea5f8051 Bump version to 0.7.5 2024-03-07 14:47:07 +01:00
Maze
c656956365 Update changelog for 0.7.5 2024-03-07 14:47:02 +01:00
Tim Clifford
bab99443b6 Minor fixes for OpenBSD 2024-03-04 11:31:30 +01:00
gridtime
5959e6e35a
raises mistune version in requirements 2024-03-04 11:17:11 +01:00
gridtime
6a4b8d9199
fixes mistune version condition 2024-03-04 11:16:56 +01:00
gridtime
473205007f
fixes mistune plugin for mistune v3 2024-03-04 10:45:20 +01:00
Tim Clifford
e99b7584d2 Add emoji processing 2024-02-26 19:01:38 +01:00
Tim Clifford
294bd887ba Catch some reaction failure modes, make spoilers black bars 2024-02-26 19:00:14 +01:00
Tim Clifford
8eb1afb91c pep8 fix 2024-02-26 19:00:14 +01:00
Tim Clifford
160670bea3 Add /spoiler and /unspoiler 2024-02-26 19:00:14 +01:00
Tim Clifford
022df56c9e add /react 2024-02-26 19:00:14 +01:00
Tim Clifford
7d6fba5ac7 Refactor /cmd handling to be more extensible 2024-02-26 19:00:14 +01:00
Stefan Hansson
4de8e87f06 Replace custom Purism::form_factor with display_length
See https://github.com/ximion/appstream/issues/476
2024-02-26 18:54:50 +01:00
gridtime
8521182a09
removes unneccessary set casts 2024-02-06 12:12:56 +01:00
gridtime
456525aa25
raises allowed html_sanitizer version in requirements.txt 2024-02-06 11:49:02 +01:00
gridtime
0e6e55a920
adjusts html saniziter settings to match expected types 2024-02-06 11:48:03 +01:00
10 changed files with 395 additions and 48 deletions

View File

@ -6,6 +6,7 @@ The format is based on
and this project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- [0.7.5 (2024-03-07)](#075-2024-03-07)
- [0.7.4 (2024-01-04)](#074-2024-01-04)
- [0.7.3 (2022-01-31)](#073-2022-01-31)
- [0.7.2 (2021-07-26)](#072-2021-07-26)
@ -25,6 +26,30 @@ and this project adheres to
- [0.4.0 (2020-03-21)](#040-2020-03-21)
## 0.7.5 (2024-03-07)
### Added
- Added emoji processing, for example `:eyes:`
- Added /react command, to add (emoji) reactions
- Added displaying of spoiler tags
- Added /spoiler and /unspoiler commands
### Changed
- Now using Mistune 3
### Fixed
- Fixed profile picture flickering in account settings UI
- Fixed overlaying tabs glitch in many UIs
- Fixed some issues when running on OpenBSD
## 0.7.4 (2024-01-04)
### Added

View File

@ -25,6 +25,7 @@ but compiling on Windows and macOS should be possible with the right tools.
- [Common issues](#common-issues)
- [cffi version mismatch](#cffi-version-mismatch)
- [Type XYZ unavailable](#type-xyz-unavailable)
- [libimagequant.so.0: cannot open shared object file: No such file or directory](#libimagequantso0-cannot-open-shared-object-file-no-such-file-or-directory)
## Packages
@ -379,3 +380,12 @@ sudo chmod 755 /usr/lib/qt5/qml/io/thp/pyotherside/*.so
Note that the Qt lib path might be `/usr/lib/qt/` instead of `/usr/lib/qt5/`,
depending on the distro.
#### libimagequant.so.0: cannot open shared object file: No such file or directory
Solution from [here](https://stackoverflow.com/questions/77499381/libimagequant-so-0-cannot-open-shared-object-file-no-such-file-or-directory) works.
```sh
sudo ln /lib/libimagequant.so.0.4 /lib/libimagequant.so.0
```

View File

@ -23,6 +23,9 @@
<provides>
<binary>moment</binary>
</provides>
<requires>
<display_length compare="ge">360</display_length>
</requires>
<project_license>LGPL-3.0-or-later</project_license>
<screenshots>
<screenshot type="default">
@ -56,6 +59,7 @@
</screenshots>
<metadata_license>FSFAP</metadata_license>
<releases>
<release version="0.7.5" date="2024-03-07"/>
<release version="0.7.4" date="2024-01-04"/>
<release version="0.7.3" date="2022-01-31"/>
<release version="0.7.2" date="2021-07-26"/>
@ -74,8 +78,4 @@
<release version="0.4.1" date="2020-03-23"/>
<release version="0.4.0" date="2020-03-21"/>
</releases>
<custom>
<value key="Purism::form_factor">workstation</value>
<value key="Purism::form_factor">mobile</value>
</custom>
</component>

View File

@ -4,10 +4,10 @@ appdirs >= 1.4.4, < 2
cairosvg >= 2.4.2, < 3
emoji >= 2.0, < 3.0
filetype >= 1.0.7, < 2
html_sanitizer >= 1.9.1, < 2
lxml >= 4.5.1, < 5
mistune >= 2.0.0, < 3.0
pymediainfo >= 4.2.1, < 5
html_sanitizer >= 1.9.1, < 3
lxml >= 4.5.1, < 6
mistune >= 2.0.0, < 4.0
pymediainfo >= 4.2.1, < 7
plyer >= 1.4.3, < 2
sortedcontainers >= 2.2.2, < 3
watchgod >= 0.7, < 0.8

View File

@ -16,4 +16,4 @@ documentation in the following modules first:
__app_name__ = "moment"
__display_name__ = "Moment"
__reverse_dns__ = "xyz.mx-moment"
__version__ = "0.7.4"
__version__ = "0.7.5"

View File

@ -20,7 +20,16 @@ import aiohttp
import nio
import plyer
import pyotherside
import simpleaudio
has_simpleaudio = True
try:
import simpleaudio
except ImportError as e:
trace = traceback.format_exc().rstrip()
log.error("Importing simpleaudio failed\n%s", trace)
has_simpleaudio = False
from appdirs import AppDirs
from nio.client.async_client import client_session
@ -590,6 +599,13 @@ class Backend:
async def sound_notify(self) -> None:
if not has_simpleaudio:
if self.audio_working:
log.error("Playing audio not supported as python-simpleaudio is not installed")
self.audio_working = False
return
path = self.settings.Notifications.default_sound
path = str(Path(path).expanduser())

View File

@ -10,6 +10,7 @@ from urllib.parse import unquote
import html_sanitizer.sanitizer as sanitizer
import lxml.html # nosec
import emoji
import mistune
import nio
from html_sanitizer.sanitizer import Sanitizer
@ -40,7 +41,10 @@ def plugin_matrix(md):
# Mark colour as high priority as otherwise e.g. <red>(hi) matches the
# inline_html rule instead of the colour rule.
md.inline.rules.insert(1, "colour")
md.inline.register_rule("colour", colour, parse_colour)
if mistune.__version__.startswith("2."): # v2
md.inline.register_rule("colour", colour, parse_colour)
else:
md.inline.register("colour", colour, parse_colour)
if md.renderer.NAME == "html":
md.renderer.register("colour", render_html_colour)
@ -215,6 +219,14 @@ class HTMLProcessor:
if not outgoing:
self._matrix_to_links_add_classes(a_tag)
for node in tree.iterdescendants():
if node.tag != "code" and node.text:
node.text = emoji.emojize(
node.text, language="alias", variant="emoji_type")
if node.getparent() and node.getparent().tag != "code" and node.tail:
node.tail = emoji.emojize(
node.tail, language="alias", variant="emoji_type")
html = etree.tostring(tree, encoding="utf-8", method="html").decode()
html = sanit.sanitize(html).rstrip("\n")
@ -223,6 +235,27 @@ class HTMLProcessor:
# Client-side modifications
# re-parsing, will sanitize again but allowing style
tree = etree.fromstring(
html, parser=etree.HTMLParser(encoding="utf-8"),
)
for span_tag in tree.iterfind(".//span[@data-mx-spoiler]"):
# if there are sub-elements, their styles also need to be set or
# background-color doesn't seem to apply
for tag in span_tag.iter():
tag.set(
"style",
"color: black !important; background-color: black !important;"
+ (tag.get("style") or "")
)
html = etree.tostring(tree, encoding="utf-8", method="html").decode()
html = Sanitizer(self.sanitize_settings(
inline, outgoing, mentions, extra_attributes={"style"}
)).sanitize(html).rstrip("\n")
html = self.quote_regex.sub(r'\1<span class="quote">\2</span>\3', html)
if not inline:
@ -238,6 +271,7 @@ class HTMLProcessor:
inline: bool = False,
outgoing: bool = False,
display_name_mentions: Optional[Dict[str, str]] = None,
extra_attributes: set = set(),
) -> dict:
"""Return an html_sanitizer configuration."""
@ -250,16 +284,21 @@ class HTMLProcessor:
"font": {"color"},
"a": {"href", "class", "data-mention"},
"code": {"class"},
"span": {"data-mx-spoiler", "data-mx-color"},
}
attributes = {**inlines_attributes, **{
"ol": {"start"},
"hr": {"width"},
"span": {"data-mx-color"},
"img": {
"data-mx-emote", "src", "alt", "title", "width", "height",
},
}}
for key in inlines_attributes:
inlines_attributes[key] |= extra_attributes
for key in attributes:
attributes[key] |= extra_attributes
username_link_regexes = [re.compile(r) for r in [
rf"(?<!\w)(?P<body>{re.escape(name or user_id)})(?!\w)(?P<host>)"
for user_id, name in (display_name_mentions or {}).items()
@ -272,7 +311,7 @@ class HTMLProcessor:
"separate": {"a"} if inline else {
"a", "p", "li", "table", "tr", "th", "td", "br", "hr", "img",
},
"whitespace": {},
"whitespace": set(),
"keep_typographic_whitespace": True,
"add_nofollow": False,
"autolink": {

View File

@ -151,7 +151,6 @@ class MatrixClient(nio.AsyncClient):
"m.call.*",
"m.room.third_party_invite",
"m.room.tombstone",
"m.reaction",
],
},
},
@ -179,6 +178,24 @@ class MatrixClient(nio.AsyncClient):
if host in ("127.0.0.1", "localhost", "::1"):
proxy = None
# self is passed in explicitly, so that it will also be passed to
# handlers in the user config
self.cmd_handler_map = {
"me ": MatrixClient.handle_cmd_emote,
"react ": MatrixClient.handle_cmd_react,
"spoiler ": MatrixClient.handle_cmd_spoiler,
"unspoiler": MatrixClient.handle_cmd_unspoiler,
}
try:
self.cmd_handler_map = {
**self.cmd_handler_map,
**backend.settings.Commands.get_cmd_handler_map(),
}
except (AttributeError, KeyError):
# make sure we don't break older configs
pass
super().__init__(
homeserver = homeserver,
user = user,
@ -238,8 +255,6 @@ class MatrixClient(nio.AsyncClient):
# {room_id: event}
self.power_level_events: Dict[str, nio.PowerLevelsEvent] = {}
self.invalid_disconnecting: bool = False
self.nio_callbacks = NioCallbacks(self)
@ -609,7 +624,6 @@ class MatrixClient(nio.AsyncClient):
return {**self.invited_rooms, **self.rooms}
async def send_text(
self,
room_id: str,
@ -617,22 +631,46 @@ class MatrixClient(nio.AsyncClient):
display_name_mentions: Optional[Dict[str, str]] = None, # {id: name}
reply_to_event_id: Optional[str] = None,
) -> None:
"""Send a markdown `m.text` or `m.notice` (with `/me`) message ."""
if text.startswith("//") or text.startswith(r"\/"):
await self._send_text(
room_id, text[1:], display_name_mentions, reply_to_event_id)
elif text.startswith("/"):
for k, v in self.cmd_handler_map.items():
if text.startswith("/" + k):
await v(self, room_id, text[len("/" + k):],
display_name_mentions, reply_to_event_id)
break
else:
await self.send_fake_notice(
room_id,
r"That command was not recognised. "
r"To send a message starting with /, use //",
)
else:
await self._send_text(
room_id, text, display_name_mentions, reply_to_event_id)
async def _send_text(
self,
room_id: str,
text: str,
display_name_mentions: Optional[Dict[str, str]] = None, # {id: name}
reply_to_event_id: Optional[str] = None,
override_to_html: Optional[str] = None,
override_echo_body: Optional[str] = None,
emote: bool = False,
) -> None:
"""Send a markdown `m.text` or `m.emote` message ."""
from_md = partial(
HTML.from_markdown, display_name_mentions=display_name_mentions,
)
escape = False
if text.startswith("//") or text.startswith(r"\/"):
escape = True
text = text[1:]
content: Dict[str, Any]
if text.startswith("/me ") and not escape:
if emote:
event_type = nio.RoomMessageEmote
text = text[len("/me "):]
content = {"body": text, "msgtype": "m.emote"}
to_html = from_md(text, inline=True, outgoing=True)
echo_body = from_md(text, inline=True)
@ -642,6 +680,12 @@ class MatrixClient(nio.AsyncClient):
to_html = from_md(text, outgoing=True)
echo_body = from_md(text)
# override_echo_body will not be effective if it is a reply.
# echo_body is only shown before the event is received back from the
# server, so this is fine if not ideal
to_html = override_to_html or to_html
echo_body = override_echo_body or echo_body
if to_html not in (html.escape(text), f"<p>{html.escape(text)}</p>"):
content["format"] = "org.matrix.custom.html"
content["formatted_body"] = to_html
@ -697,6 +741,171 @@ class MatrixClient(nio.AsyncClient):
await self.pause_while_offline()
await self._send_message(room_id, content, tx_id)
async def handle_cmd_emote(
self,
room_id: str,
text: str,
display_name_mentions: Optional[Dict[str, str]] = None, # {id: name}
reply_to_event_id: Optional[str] = None,
):
await self._send_text(
room_id,
text,
display_name_mentions,
reply_to_event_id,
emote=True,
)
async def handle_cmd_react(
self,
room_id: str,
text: str,
display_name_mentions: Optional[Dict[str, str]] = None, # {id: name}
reply_to_event_id: Optional[str] = None,
) -> None:
if reply_to_event_id is None or reply_to_event_id == "":
await self.send_fake_notice(
room_id, "Please reply to a message to react to it")
else:
reaction = emoji.emojize(
text, language="alias", variant="emoji_type")
await self.send_reaction(room_id, reaction, reply_to_event_id)
async def handle_cmd_spoiler(
self,
room_id: str,
text: str,
display_name_mentions: Optional[Dict[str, str]] = None, # {id: name}
reply_to_event_id: Optional[str] = None,
) -> None:
from_md = partial(
HTML.from_markdown, display_name_mentions=display_name_mentions,
)
to_html = from_md(text, outgoing=True)
echo_body = from_md(text)
if to_html.startswith("<p>") and to_html.endswith("</p>"):
# we want to make sure the <span> is inside the <p>, otherwise the
# black bar will be too wide
inner_html = to_html[len('<p>'):-len('</p>')]
to_html = f"<p><span data-mx-spoiler>{inner_html}</span></p>"
else:
to_html = f"<span data-mx-spoiler>{to_html}</span>"
if echo_body.startswith("<p>") and echo_body.endswith("</p>"):
inner_html = echo_body[len('<p>'):-len('</p>')]
echo_body = f"<p><span data-mx-spoiler>{inner_html}</span></p>"
else:
echo_body = f"<span data-mx-spoiler>{echo_body}</span>"
await self._send_text(
room_id,
text,
display_name_mentions,
reply_to_event_id,
override_to_html = to_html,
override_echo_body = echo_body,
)
async def handle_cmd_unspoiler(
self,
room_id: str,
text: str,
display_name_mentions: Optional[Dict[str, str]] = None, # {id: name}
reply_to_event_id: Optional[str] = None,
) -> None:
if reply_to_event_id is None or reply_to_event_id == "":
await self.send_fake_notice(
room_id,
"Please reply to a message with /unspoiler to unspoiler it",
)
else:
spoiler_event: Event = \
self.models[self.user_id, room_id, "events"][reply_to_event_id]
# get formatted_body, fallback to body,
spoiler = getattr(spoiler_event.source, "formatted_body", None) \
or getattr(spoiler_event.source, "body", "")
unspoiler = re.sub(
r"<span[^>]+data-mx-spoiler[^>]*>(.*?)</?span>", r"\1",
spoiler,
)
await self.send_fake_notice(room_id, unspoiler)
async def send_reaction(
self,
room_id: str,
key: str,
reacts_to: str,
) -> None:
# local event id in model isn't necessarily the actual event id
reacts_to_event = self.models[
self.user_id, room_id, "events"][reacts_to]
reacts_to_event_id = reacts_to_event.event_id
if self.user_id in reacts_to_event.reactions.get(key, {}).get('users', []):
await self.send_fake_notice(
room_id,
"Can't send the same reaction more than once",
)
return
elif reacts_to_event_id.startswith("echo-"):
await self.send_fake_notice(
room_id,
"Can't react to that, it's not a real event",
)
return
item_uuid = uuid4()
content: Dict[str, Any] = {
"m.relates_to": {
"rel_type": "m.annotation",
"event_id": reacts_to_event_id,
"key": key,
},
}
tx_id = uuid4()
content[f"{__reverse_dns__}.transaction_id"] = str(tx_id)
await self.pause_while_offline()
try:
await self._send_message(
room_id, content, item_uuid, message_type = "m.reaction")
except MatrixError as err:
if err.m_code == "M_DUPLICATE_ANNOTATION":
# potentially possible if the new reaction is
# sent before the existing reaction is loaded
await self.send_fake_notice(
room_id,
"Can't send the same reaction more than once",
)
return
if err.m_code == "M_UNKNOWN":
await self.send_fake_notice(
room_id,
"Failed to send reaction. Has the event you are reacting to fully sent yet?",
)
else:
raise err
# only update the UI after the reaction is sent, to not be misleading
await self._register_reaction(
self.all_rooms[room_id],
reacts_to_event_id,
key,
self.user_id,
)
async def toggle_pause_transfer(
self, room_id: str, uuid: Union[str, UUID],
@ -748,7 +957,6 @@ class MatrixClient(nio.AsyncClient):
reply_to_event_id: Optional[str] = None,
) -> None:
"""Send a `m.file`, `m.image`, `m.audio` or `m.video` message.
The Matrix client-server API states that media messages can't have a
reply attached.
Thus, if a `reply_to_event_id` is passed, we send a pseudo-reply as two
@ -1022,6 +1230,7 @@ class MatrixClient(nio.AsyncClient):
room_id: str,
transaction_id: UUID,
event_type: Type[nio.Event],
fake_event: bool = False,
**event_fields,
) -> None:
"""Register a local model `Event` while waiting for the server.
@ -1049,13 +1258,13 @@ class MatrixClient(nio.AsyncClient):
event = Event(
id = f"echo-{transaction_id}",
event_id = "",
event_id = f"echo-{transaction_id}" if fake_event else "",
event_type = event_type,
date = datetime.now(),
sender_id = self.user_id,
sender_name = our_info.display_name,
sender_avatar = our_info.avatar_url,
is_local_echo = True,
is_local_echo = not fake_event,
links = Event.parse_links(content),
**event_fields,
)
@ -1067,9 +1276,18 @@ class MatrixClient(nio.AsyncClient):
await self.set_room_last_event(room_id, event)
async def send_fake_notice(self, room_id, msg):
await self._local_echo(
room_id,
uuid4(),
nio.RoomMessageNotice,
content = msg,
fake_event = True,
)
async def _send_message(
self, room_id: str, content: dict, transaction_id: UUID,
message_type: str = "m.room.message",
) -> None:
"""Send a message event with `content` dict to a room."""
@ -1079,7 +1297,7 @@ class MatrixClient(nio.AsyncClient):
async with self.backend.send_locks[room_id]:
await self.room_send(
room_id = room_id,
message_type = "m.room.message",
message_type = message_type,
content = content,
ignore_unverified_devices = True,
)
@ -2432,13 +2650,23 @@ class MatrixClient(nio.AsyncClient):
self,
room: nio.MatrixRoom,
ev: nio.ReactionEvent,
event_id: str = "",
event_id: str = "",
**fields,
) -> Event:
) -> None:
await self._register_reaction(room, ev.reacts_to, ev.key, ev.sender)
await self.register_nio_event(
room, ev, event_id, type_specifier=TypeSpecifier.Reaction,
content=ev.key, hidden=True, **fields,
)
async def _register_reaction(
self,
room: nio.MatrixRoom,
reacts_to: str,
key: str,
sender: str,
) -> None:
"""Register/update a reaction."""
reacts_to = ev.reacts_to
key = ev.key
sender = ev.sender
model = self.models[self.user_id, room.room_id, "events"]
reacts_to_event = model.get(reacts_to)
@ -2451,7 +2679,8 @@ class MatrixClient(nio.AsyncClient):
if reacts_to_event:
reactions = reacts_to_event.reactions
if key not in reactions:
reactions[key] = {"hint": emoji.demojize(key), "users": []}
reactions[key] = {
"hint": emoji.demojize(key, language="alias"), "users": []}
if sender not in reactions[key]["users"]:
reactions[key]["users"].append(sender)
reacts_to_event.set_fields(reactions=reactions)
@ -2467,11 +2696,6 @@ class MatrixClient(nio.AsyncClient):
if sender not in registry[reacts_to][key]:
registry[reacts_to][key].append(sender)
await self.register_nio_event(
room, ev, event_id, type_specifier=TypeSpecifier.Reaction,
content=key, hidden=True, **fields,
)
async def register_message_replacement(
self,
room: nio.MatrixRoom,
@ -2689,8 +2913,8 @@ class MatrixClient(nio.AsyncClient):
)
sender = item.sender_name or item.sender_id
is_linux = platform.system() == "Linux"
use_html = is_linux and self.backend.settings.Notifications.use_html
is_nux = platform.system() == "Linux" or "BSD" in platform.system()
use_html = is_nux and self.backend.settings.Notifications.use_html
content = item.inline_content if use_html else item.plain_content
if isinstance(ev, nio.RoomMessageEmote) and use_html:

View File

@ -541,3 +541,36 @@ class Keys:
# Sign out checked sessions if any, else sign out all sessions.
sign_out = ["Alt+S", "Delete"]
class Commands:
# If you are an advanced user, here you can define new /commands
#
# get_cmd_handler_map should return a dictionary of the form
# {
# "command": command_handling_function,
# "another_command": ...
# }
#
# each command_handling_function should have the signature:
#
# async def command_handling_function(
# matrix_client: moment.backend.MatrixClient,
# room_id: str,
# text: str, # text after the end of /command
# display_name_mentions: Optional[Dict[str, str]] = None, # {id: name}
# reply_to_event_id: Optional[str] = None,
# ) -> None:
# Example:
def get_cmd_handler_map(self):
return {
# "rot13 ": self.handle_cmd_rot13,
}
# @staticmethod
# def handle_cmd_rot13(matrix_client, room_id, text, display_name_mentions, reply_to_event_id):
# import codecs
# matrix_client._send_text(
# room_id, codecs.encode(text, 'rot_13'), display_name_mentions, reply_to_event_id
# )

View File

@ -367,7 +367,7 @@ int main(int argc, char *argv[]) {
QApplication::setOrganizationName("moment");
QApplication::setApplicationName("moment");
QApplication::setApplicationDisplayName("Moment");
QApplication::setApplicationVersion("0.7.4");
QApplication::setApplicationVersion("0.7.5");
QApplication::setAttribute(Qt::AA_EnableHighDpiScaling);
// app needs to be constructed before attempting to migrate