Merged with 0.7.4

This commit is contained in:
Zergling_man 2024-01-22 22:14:11 +11:00
commit b248771619
24 changed files with 1514 additions and 51 deletions

1
.gitignore vendored
View File

@ -3,6 +3,7 @@ __pycache__
*.egg-info *.egg-info
*.pyc *.pyc
venv venv
sitecustomize.py
*.qmlc *.qmlc
*.jsc *.jsc

View File

@ -11,6 +11,7 @@ Use `pip3 install --user -U requirements-dev.txt` before running this."""
import os import os
import subprocess import subprocess
import shutil
import sys import sys
from contextlib import suppress from contextlib import suppress
from pathlib import Path from pathlib import Path
@ -49,8 +50,13 @@ def cmd(*parts) -> subprocess.CompletedProcess:
def run_app(args=sys.argv[1:]) -> None: def run_app(args=sys.argv[1:]) -> None:
print("\n\x1b[36m", "" * term_size().columns, "\x1b[0m\n", sep="") print("\n\x1b[36m", "" * term_size().columns, "\x1b[0m\n", sep="")
if shutil.which("qmake-qt5"):
QMAKE_CMD = "qmake-qt5"
else:
QMAKE_CMD = "qmake"
with suppress(KeyboardInterrupt): with suppress(KeyboardInterrupt):
cmd("qmake", "moment.pro", "CONFIG+=dev") cmd(QMAKE_CMD, "moment.pro", "CONFIG+=dev")
cmd("make") cmd("make")
cmd("./moment", "-name", "dev", *args) cmd("./moment", "-name", "dev", *args)

View File

@ -6,6 +6,7 @@ The format is based on
and this project adheres to and this project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html). [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- [0.7.4 (2024-01-04)](#074-2024-01-04)
- [0.7.3 (2022-01-31)](#073-2022-01-31) - [0.7.3 (2022-01-31)](#073-2022-01-31)
- [0.7.2 (2021-07-26)](#072-2021-07-26) - [0.7.2 (2021-07-26)](#072-2021-07-26)
- [0.7.1 (2021-03-04)](#071-2021-03-04) - [0.7.1 (2021-03-04)](#071-2021-03-04)
@ -24,6 +25,34 @@ and this project adheres to
- [0.4.0 (2020-03-21)](#040-2020-03-21) - [0.4.0 (2020-03-21)](#040-2020-03-21)
## 0.7.4 (2024-01-04)
### Added
- Display emoji reactions on messages
- Display edited messages properly
### Changed
- Changed command line argument processing
- Changed server listing to use servers.joinmatrix.org
- Now using Mistune 2.0.2
- Now using upstream matrix-nio (instead of fork)
### Removed
- Almost all UI animations were removed
### Fixed
- Fixed restoring from tray
- Updated installation instructions
## 0.7.3 (2022-01-31) ## 0.7.3 (2022-01-31)
### Added ### Added

View File

@ -56,6 +56,7 @@
</screenshots> </screenshots>
<metadata_license>FSFAP</metadata_license> <metadata_license>FSFAP</metadata_license>
<releases> <releases>
<release version="0.7.4" date="2024-01-04"/>
<release version="0.7.3" date="2022-01-31"/> <release version="0.7.3" date="2022-01-31"/>
<release version="0.7.2" date="2021-07-26"/> <release version="0.7.2" date="2021-07-26"/>
<release version="0.7.1" date="2021-03-04"/> <release version="0.7.1" date="2021-03-04"/>

View File

@ -1,15 +1,15 @@
remote_pdb >= 2.0.0, < 3 remote_pdb >= 2.0.0, < 3
pdbpp >= 0.10.2, < 0.11 pdbpp >= 0.10.2, < 0.11
devtools >= 0.4.0, < 0.5 devtools >= 0.12.0, < 0.13
mypy >= 0.812, < 0.900 mypy >= 1.7.0, < 1.8
flake8 >= 3.8.4, < 4 flake8 >= 6.1.0, < 7
flake8-isort >= 4.0.0, < 5 flake8-isort >= 6.1.0, < 7
flake8-bugbear >= 20.1.4, < 21 flake8-bugbear >= 23.12.0, < 24
flake8-commas >= 2.0.0, < 3 flake8-commas >= 2.0.0, < 3
flake8-comprehensions >= 3.3.0, < 4 flake8-comprehensions >= 3.3.0, < 4
flake8-executable >= 2.0.4, < 3 flake8-executable >= 2.0.4, < 3
flake8-logging-format >= 0.6.0, < 0.7 flake8-logging-format >= 0.9.0, < 1
flake8-pie >= 0.6.1, < 0.7 flake8-pie >= 0.16.0, < 1
flake8-quotes >= 3.2.0, < 4 flake8-quotes >= 3.2.0, < 4
flake8-colors >= 0.1.6, < 0.2 flake8-colors >= 0.1.6, < 0.2

View File

@ -2,6 +2,7 @@ Pillow >= 7.0.0, < 9
aiofiles >= 0.4.0, < 24.0.0 aiofiles >= 0.4.0, < 24.0.0
appdirs >= 1.4.4, < 2 appdirs >= 1.4.4, < 2
cairosvg >= 2.4.2, < 3 cairosvg >= 2.4.2, < 3
emoji >= 2.0, < 3.0
filetype >= 1.0.7, < 2 filetype >= 1.0.7, < 2
html_sanitizer >= 1.9.1, < 2 html_sanitizer >= 1.9.1, < 2
lxml >= 4.5.1, < 5 lxml >= 4.5.1, < 5
@ -14,7 +15,7 @@ redbaron >= 0.9.2, < 1
hsluv >= 5.0.0, < 6 hsluv >= 5.0.0, < 6
simpleaudio >= 1.0.4, < 2 simpleaudio >= 1.0.4, < 2
dbus-python >= 1.2.16, < 2; platform_system == "Linux" dbus-python >= 1.2.16, < 2; platform_system == "Linux"
matrix-nio[e2e] >= 0.20.1, < 1.0.0 matrix-nio[e2e] >= 0.22.0, < 0.24
async_generator >= 1.10, < 2; python_version < "3.7" async_generator >= 1.10, < 2; python_version < "3.7"
dataclasses >= 0.6, < 0.7; python_version < "3.7" dataclasses >= 0.6, < 0.7; python_version < "3.7"

View File

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

View File

@ -533,7 +533,7 @@ class Backend:
connector = session.connector, connector = session.connector,
) )
api_list = "https://joinmatrix.org/servers.json" api_list = "https://servers.joinmatrix.org/servers.json"
try: try:
response = await session.get(api_list) response = await session.get(api_list)
except: except:
@ -543,17 +543,19 @@ class Backend:
coros = [] coros = []
for server in (await response.json()): for server in (await response.json())["public_servers"]:
homeserver_url = "https://" + server["domain"] homeserver_url = "https://" + server["client_domain"]
if not server["open"]: # ignore closed servers http_s_re = re.compile("^https?://")
continue # remove https from homepage because it will be re-added later
site_url = (http_s_re.sub("", server["homepage"])
if "homepage" in server else server["client_domain"])
self.models["homeservers"][homeserver_url] = Homeserver( self.models["homeservers"][homeserver_url] = Homeserver(
id = homeserver_url, id = homeserver_url,
name = server["name"], name = server["name"],
site_url = server["domain"], site_url = site_url,
country = server["jurisdiction"], country = server["staff_jur"],
stability = 0, stability = 0,
downtimes_ms = 0, downtimes_ms = 0,
# austin's list doesn't have stability/downtime # austin's list doesn't have stability/downtime
@ -609,4 +611,4 @@ class Backend:
if self.audio_working: if self.audio_working:
trace = traceback.format_exc().rstrip() trace = traceback.format_exc().rstrip()
log.error("Playing audio failed\n%s", trace) log.error("Playing audio failed\n%s", trace)
self.audio_working = False self.audio_working = False

View File

@ -26,6 +26,7 @@ from urllib.parse import urlparse
from uuid import UUID, uuid4 from uuid import UUID, uuid4
import cairosvg import cairosvg
import emoji
import nio import nio
from nio.crypto import AsyncDataT as UploadData from nio.crypto import AsyncDataT as UploadData
from nio.crypto import async_generator_from_data from nio.crypto import async_generator_from_data
@ -114,6 +115,7 @@ class MatrixImageInfo(NamedTuple):
class MatrixClient(nio.AsyncClient): class MatrixClient(nio.AsyncClient):
<<<<<<< HEAD
"""A client for an account to interact with a matrix homeserver.""" """A client for an account to interact with a matrix homeserver."""
user_id_regex = re.compile(r"^@.+:.+") user_id_regex = re.compile(r"^@.+:.+")
@ -225,6 +227,12 @@ class MatrixClient(nio.AsyncClient):
self.unassigned_event_last_read_by: DefaultDict[str, Dict[str, int]] =\ self.unassigned_event_last_read_by: DefaultDict[str, Dict[str, int]] =\
DefaultDict(dict) DefaultDict(dict)
# {reacted_event_id: {emoji: [user_id]}}
self.unassigned_reaction_events: Dict[str, Dict[str, List[str]]] = {}
# {replaced_event_id: [replace_event]}}
self.unassigned_replace_events: Dict[str, List[Dict[str, str]]] = {}
self.push_rules: nio.PushRulesEvent = nio.PushRulesEvent() self.push_rules: nio.PushRulesEvent = nio.PushRulesEvent()
self.ignored_user_ids: Set[str] = set() self.ignored_user_ids: Set[str] = set()
@ -241,7 +249,6 @@ class MatrixClient(nio.AsyncClient):
type(self).__name__, self.user_id, self.homeserver, self.device_id, type(self).__name__, self.user_id, self.homeserver, self.device_id,
) )
@property @property
def default_device_name(self) -> str: def default_device_name(self) -> str:
"""Device name to set at login if the user hasn't set a custom one.""" """Device name to set at login if the user hasn't set a custom one."""
@ -359,7 +366,7 @@ class MatrixClient(nio.AsyncClient):
timeout = 10, timeout = 10,
) )
except asyncio.TimeoutError: except asyncio.TimeoutError:
log.warn("%s timed out", self.user_id) log.warning("%s timed out", self.user_id)
await self.close() await self.close()
@ -377,7 +384,7 @@ class MatrixClient(nio.AsyncClient):
account.max_upload_size = future.result() or 0 account.max_upload_size = future.result() or 0
except MatrixError: except MatrixError:
trace = traceback.format_exc().rstrip() trace = traceback.format_exc().rstrip()
log.warn( log.warning(
"On %s server config retrieval: %s", self.user_id, trace, "On %s server config retrieval: %s", self.user_id, trace,
) )
self.server_config_task = asyncio.ensure_future( self.server_config_task = asyncio.ensure_future(
@ -2422,6 +2429,118 @@ class MatrixClient(nio.AsyncClient):
self.backend.notification_avatar_cache[mxc] = path self.backend.notification_avatar_cache[mxc] = path
return path return path
async def register_reaction(
self,
room: nio.MatrixRoom,
ev: nio.ReactionEvent,
event_id: str = "",
**fields,
) -> Event:
"""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)
if not reacts_to_event: # local echo
for item in model.values():
if item.event_id == reacts_to:
reacts_to_event = item
# message is already loaded: update reactions instantly
if reacts_to_event:
reactions = reacts_to_event.reactions
if key not in reactions:
reactions[key] = {"hint": emoji.demojize(key), "users": []}
if sender not in reactions[key]["users"]:
reactions[key]["users"].append(sender)
reacts_to_event.set_fields(reactions=reactions)
reacts_to_event.notify_change("reactions")
# message is not loaded yet: register the reaction for later update
else:
registry = self.unassigned_reaction_events
if reacts_to not in registry:
registry[reacts_to] = {}
if key not in registry[reacts_to]:
registry[reacts_to][key] = []
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,
ev: Union[nio.Event, nio.BadEvent],
event_id: str = "",
override_fetch_profile: Optional[bool] = None,
**fields,
) -> Event:
"""Register/update a message replacement."""
event_id = event_id or ev.event_id
relates_to = ev.source.get("content", {}).get("m.relates_to", {})
replaced_event_id = relates_to.get("event_id")
model = self.models[self.user_id, room.room_id, "events"]
replaced_event = model.get(replaced_event_id)
if not replaced_event: # local echo
for item in model.values():
if item.event_id == replaced_event_id:
replaced_event = item
# content
content = fields.get("content", "").strip()
inline_content = fields.get("inline_content", "").strip()
if content and "inline_content" not in fields:
inline_content = HTML.filter(content, inline=True)
content_history = {
"id": event_id,
"date": datetime.fromtimestamp(ev.server_timestamp / 1000),
"content": content,
"content_diff": "",
"inline_content": inline_content,
"body": ev.source.get("content", {})
.get("m.new_content", {})
.get("body") or inline_content,
"links": Event.parse_links(content),
}
# message is already loaded: update message instantly
if replaced_event:
history = replaced_event.content_history or []
if history:
content_history["content_diff"] = utils.diff_body(
history[-1]["content"], content_history["content"])
history.append(content_history)
replaced_event.set_fields(
replaced = True,
content = content,
inline_content = inline_content,
content_history = history,
)
replaced_event.source.body = content_history["body"]
replaced_event.notify_change(
"replaced", "content", "inline_content", "content_history")
# message not loaded yet: register the replacement for later update
else:
if replaced_event_id not in self.unassigned_replace_events:
self.unassigned_replace_events[replaced_event_id] = []
self.unassigned_replace_events[replaced_event_id].append(
content_history)
await self.register_nio_event(
room, ev, event_id, override_fetch_profile,
type_specifier=TypeSpecifier.MessageReplace,
hidden=True, **fields,
)
async def register_nio_event( async def register_nio_event(
self, self,
@ -2485,6 +2604,15 @@ class MatrixClient(nio.AsyncClient):
**fields, **fields,
) )
item.content_history = [{
"id": item.id,
"date": item.date,
"content": item.content,
"content_diff": item.content,
"inline_content": item.inline_content,
"body": ev.source.get("content", {}).get("body", item.content),
"links": item.links,
}]
# Add the Event to model # Add the Event to model
@ -2498,6 +2626,32 @@ class MatrixClient(nio.AsyncClient):
if from_us and tx_id and f"echo-{tx_id}" in model: if from_us and tx_id and f"echo-{tx_id}" in model:
item.id = f"echo-{tx_id}" item.id = f"echo-{tx_id}"
self.event_to_echo_ids[ev.event_id] = item.id self.event_to_echo_ids[ev.event_id] = item.id
reactions = self.unassigned_reaction_events.get(item.id, {})
for key, senders in reactions.items(): # update reactions
if key not in item.reactions:
item.reactions[key] = {
"hint": emoji.demojize(key),
"users": [],
}
item.reactions[key]["users"] += senders
if ev.source.get("type") == "m.reaction" \
and ev.source.get("unsigned", {}).get("redacted_by"):
item.type_specifier = TypeSpecifier.ReactionRedaction
item.hidden = True
replace_events = self.unassigned_replace_events.get(item.id)
if replace_events:
item.replaced = True
item.content_history += sorted(
replace_events, key=lambda r: r["date"])
for index in range(1, len(item.content_history)):
item.content_history[index]["content_diff"] = utils.diff_body(
item.content_history[index - 1]["body"],
item.content_history[index]["body"])
item.content = item.content_history[-1]["content"]
item.inline_content = item.content_history[-1]["inline_content"]
item.source.body = item.content_history[-1]["body"]
del self.unassigned_replace_events[item.id]
model[item.id] = item model[item.id] = item
await self.set_room_last_event(room.room_id, item) await self.set_room_last_event(room.room_id, item)
@ -2572,4 +2726,4 @@ class MatrixClient(nio.AsyncClient):
) if item.sender_avatar else "", ) if item.sender_avatar else "",
) )
return item return item

View File

@ -14,7 +14,7 @@ import lxml # nosec
import nio import nio
from ..presence import Presence from ..presence import Presence
from ..utils import AutoStrEnum, auto, strip_html_tags from ..utils import AutoStrEnum, auto, strip_html_tags, serialize_value_for_qml
from .model_item import ModelItem from .model_item import ModelItem
OptionalExceptionType = Union[Type[None], Type[Exception]] OptionalExceptionType = Union[Type[None], Type[Exception]]
@ -28,7 +28,9 @@ class TypeSpecifier(AutoStrEnum):
Unset = auto() Unset = auto()
ProfileChange = auto() ProfileChange = auto()
MembershipChange = auto() MembershipChange = auto()
Reaction = auto()
ReactionRedaction = auto()
MessageReplace = auto()
class PingStatus(AutoStrEnum): class PingStatus(AutoStrEnum):
"""Enum for the status of a homeserver ping operation.""" """Enum for the status of a homeserver ping operation."""
@ -349,6 +351,7 @@ class Event(ModelItem):
sender_name: str = field() sender_name: str = field()
sender_avatar: str = field() sender_avatar: str = field()
fetch_profile: bool = False fetch_profile: bool = False
hidden: bool = False
content: str = "" content: str = ""
inline_content: str = "" inline_content: str = ""
@ -356,6 +359,11 @@ class Event(ModelItem):
links: List[str] = field(default_factory=list) links: List[str] = field(default_factory=list)
mentions: List[Tuple[str, str]] = field(default_factory=list) mentions: List[Tuple[str, str]] = field(default_factory=list)
reactions: Dict[str, Dict[str, Any]] = field(default_factory=dict)
replaced: bool = False
content_history: List[Dict[str, Any]] = field(default_factory=list)
type_specifier: TypeSpecifier = TypeSpecifier.Unset type_specifier: TypeSpecifier = TypeSpecifier.Unset
target_id: str = "" target_id: str = ""
@ -433,5 +441,7 @@ class Event(ModelItem):
if field == "source": if field == "source":
source_dict = asdict(self.source) if self.source else {} source_dict = asdict(self.source) if self.source else {}
return json.dumps(source_dict) return json.dumps(source_dict)
if field == "content_history":
return serialize_value_for_qml(self.content_history)
return super().serialized_field(field) return super().serialized_field(field)

View File

@ -619,7 +619,6 @@ class NioCallbacks:
await self.client.register_nio_event(room, ev, content=co) await self.client.register_nio_event(room, ev, content=co)
async def onRoomNameEvent( async def onRoomNameEvent(
self, room: nio.MatrixRoom, ev: nio.RoomNameEvent, self, room: nio.MatrixRoom, ev: nio.RoomNameEvent,
) -> None: ) -> None:
@ -630,6 +629,470 @@ class NioCallbacks:
await self.client.register_nio_event(room, ev, content=co) await self.client.register_nio_event(room, ev, content=co)
# Room events, invite events and misc events callbacks
async def onRoomMessageText(
self, room: nio.MatrixRoom, ev: nio.RoomMessageText,
) -> None:
co = HTML_PROCESSOR.filter(
ev.formatted_body
if ev.format == "org.matrix.custom.html" else
plain2html(ev.body),
)
mention_list = HTML_PROCESSOR.mentions_in_html(co)
# message replacement
relates_to = ev.source.get("content", {}).get("m.relates_to", {})
if relates_to.get("rel_type") == "m.replace":
await self.client.register_message_replacement(
room, ev, content=co, mentions=mention_list,
)
return
await self.client.register_nio_event(
room, ev, content=co, mentions=mention_list,
)
async def onRoomMessageNotice(
self, room: nio.MatrixRoom, ev: nio.RoomMessageNotice,
) -> None:
await self.onRoomMessageText(room, ev)
async def onRoomMessageEmote(
self, room: nio.MatrixRoom, ev: nio.RoomMessageEmote,
) -> None:
await self.onRoomMessageText(room, ev)
async def onRoomMessageUnknown(
self, room: nio.MatrixRoom, ev: nio.RoomMessageUnknown,
) -> None:
co = f"%1 sent an unsupported <b>{escape(ev.msgtype)}</b> message"
await self.client.register_nio_event(room, ev, content=co)
async def onRoomMessageMedia(
self, room: nio.MatrixRoom, ev: nio.RoomMessageMedia,
) -> None:
info = ev.source["content"].get("info", {})
media_crypt_dict = ev.source["content"].get("file", {})
thumb_info = info.get("thumbnail_info", {})
thumb_crypt_dict = info.get("thumbnail_file", {})
try:
media_local_path: Union[Path, str] = await Media(
cache = self.client.backend.media_cache,
client_user_id = self.user_id,
mxc = ev.url,
title = ev.body,
room_id = room.room_id,
filesize = info.get("size") or 0,
crypt_dict = media_crypt_dict,
).get_local()
except FileNotFoundError:
media_local_path = ""
item = await self.client.register_nio_event(
room,
ev,
content = "",
inline_content = ev.body,
media_url = ev.url,
media_http_url = await self.client.mxc_to_http(ev.url),
media_title = ev.body,
media_width = info.get("w") or 0,
media_height = info.get("h") or 0,
media_duration = info.get("duration") or 0,
media_size = info.get("size") or 0,
media_mime = info.get("mimetype") or "",
media_crypt_dict = media_crypt_dict,
media_local_path = media_local_path,
thumbnail_url =
info.get("thumbnail_url") or thumb_crypt_dict.get("url") or "",
thumbnail_width = thumb_info.get("w") or 0,
thumbnail_height = thumb_info.get("h") or 0,
thumbnail_mime = thumb_info.get("mimetype") or "",
thumbnail_crypt_dict = thumb_crypt_dict,
)
self.client.backend.mxc_events[ev.url].append(item)
async def onRoomEncryptedMedia(
self, room: nio.MatrixRoom, ev: nio.RoomEncryptedMedia,
) -> None:
await self.onRoomMessageMedia(room, ev)
async def onReactionEvent(
self, room: nio.MatrixRoom, ev: nio.ReactionEvent,
) -> None:
await self.client.register_reaction(room, ev, ev.event_id)
async def onRedactionEvent(
self, room: nio.MatrixRoom, ev: nio.RedactionEvent,
) -> None:
model = self.models[self.user_id, room.room_id, "events"]
event = None
for existing in model._sorted_data:
if existing.event_id == ev.redacts:
event = existing
break
if not (
event and
(event.event_type is not nio.RedactedEvent or event.is_local_echo)
):
await self.client.register_nio_room(room)
return
event_type = event.source.source.get("type")
if not event_type == "m.reaction":
event.source.source["content"] = {}
event.source.source["unsigned"] = {
"redacted_by": ev.event_id,
"redacted_because": ev.source,
}
# Remove reactions
if event_type == "m.reaction":
relates_to = event.source.source.get(
"content", {}).get("m.relates_to", {})
reacted_event_id = relates_to.get("event_id")
reacted_event = model.get(reacted_event_id)
key = relates_to.get("key")
sender = ev.source.get("sender")
# Remove reactions from registry
reg = self.client.unassigned_reaction_events.get(
reacted_event_id)
if reg and key in reg and sender in reg[key]:
reg[key].remove(sender)
# Remove reactions from loaded messages
if reacted_event and key in reacted_event.reactions:
if sender in reacted_event.reactions[key]:
reacted_event.reactions[key].remove(sender)
if not reacted_event.reactions[key]:
del reacted_event.reactions[key]
reacted_event.notify_change('reactions')
await self.onRedactedEvent(
room,
nio.RedactedEvent.from_dict(event.source.source),
event_id = event.id,
)
async def onRedactedEvent(
self, room: nio.MatrixRoom, ev: nio.RedactedEvent, event_id: str = "",
) -> None:
redacter_name, _, must_fetch_redacter = \
await self.client.get_member_profile(room.room_id, ev.redacter) \
if ev.redacter else ("", "", False)
await self.client.register_nio_event(
room,
ev,
event_id = event_id,
reason = ev.reason or "",
content = await self.client.get_redacted_event_content(
type(ev), ev.redacter, ev.sender, ev.reason,
),
mentions = [],
type_specifier = TypeSpecifier.Unset,
media_url = "",
media_http_url = "",
media_title = "",
media_local_path = "",
thumbnail_url = "",
redacter_id = ev.redacter or "",
redacter_name = redacter_name,
override_fetch_profile = True,
)
async def onRoomCreateEvent(
self, room: nio.MatrixRoom, ev: nio.RoomCreateEvent,
) -> None:
co = "%1 allowed users on other matrix servers to join this room" \
if ev.federate else \
"%1 blocked users on other matrix servers from joining this room"
await self.client.register_nio_event(room, ev, content=co)
async def onRoomGuestAccessEvent(
self, room: nio.MatrixRoom, ev: nio.RoomGuestAccessEvent,
) -> None:
allowed = "allowed" if ev.guest_access == "can_join" else "forbad"
co = f"%1 {allowed} guests to join the room"
await self.client.register_nio_event(room, ev, content=co)
async def onRoomJoinRulesEvent(
self, room: nio.MatrixRoom, ev: nio.RoomJoinRulesEvent,
) -> None:
access = "public" if ev.join_rule == "public" else "invite-only"
co = f"%1 made the room {access}"
await self.client.register_nio_event(room, ev, content=co)
async def onRoomHistoryVisibilityEvent(
self, room: nio.MatrixRoom, ev: nio.RoomHistoryVisibilityEvent,
) -> None:
if ev.history_visibility == "shared":
to = "all room members"
elif ev.history_visibility == "world_readable":
to = "any member or outsider"
elif ev.history_visibility == "joined":
to = "all room members, since the time they joined"
elif ev.history_visibility == "invited":
to = "all room members, since the time they were invited"
else:
to = "???"
log.warning("Invalid visibility - %s",
json.dumps(vars(ev), indent=4))
co = f"%1 made future room history visible to {to}"
await self.client.register_nio_event(room, ev, content=co)
async def onPowerLevelsEvent(
self, room: nio.MatrixRoom, ev: nio.PowerLevelsEvent,
) -> None:
levels = ev.power_levels
stored = self.client.power_level_events.get(room.room_id)
if not stored or ev.server_timestamp > stored.server_timestamp:
self.client.power_level_events[room.room_id] = ev
try:
previous = ev.source["unsigned"]["prev_content"]
except KeyError:
previous = {}
users_previous = previous.get("users", {})
events_previous = previous.get("events", {})
changes: List[Tuple[str, int, int]] = []
event_changes: List[Tuple[str, int, int]] = []
user_changes: List[Tuple[str, int, int]] = []
def lvl(level: int) -> str:
return (
f"Admin ({level})" if level == 100 else
f"Moderator ({level})" if level >= 50 else
f"User ({level})" if level >= 0 else
f"Muted ({level})"
)
def format_defaults_dict(
levels: Dict[str, Union[int, dict]],
previous: Dict[str, Union[int, dict]],
prefix: str = "",
) -> None:
default_0 = ("users_default", "events_default", "invite")
for name in set({**levels, **previous}):
if not prefix and name in ("users", "events"):
continue
old_level = previous.get(
name, 0 if not prefix and name in default_0 else 50,
)
level = levels.get(
name, 0 if not prefix and name in default_0 else 50,
)
if isinstance(level, dict):
if not isinstance(old_level, dict):
old_level = {}
format_defaults_dict(level, old_level, f"{prefix}{name}.")
continue
if not isinstance(old_level, int):
old_level = 50
if old_level != level or not previous:
changes.append((f"{prefix}{name}", old_level, level))
format_defaults_dict(ev.source["content"], previous)
# Minimum level to send event changes
for ev_type in set({**levels.events, **events_previous}):
old_level = events_previous.get(
ev_type,
levels.defaults.state_default
if ev_type.startswith("m.room.") else
levels.defaults.events_default,
)
level = levels.events.get(
ev_type,
levels.defaults.state_default
if ev_type.startswith("m.room.") else
levels.defaults.events_default,
)
if old_level != level or not previous:
event_changes.append((ev_type, old_level, level))
# User level changes
for user_id in set({**levels.users, **users_previous}):
old_level = \
users_previous.get(user_id, levels.defaults.users_default)
level = levels.users.get(user_id, levels.defaults.users_default)
if old_level != level or not previous:
user_changes.append((user_id, old_level, level))
if user_id in room.users:
await self.client.add_member(room, user_id)
# Gather and format changes
if changes or event_changes or user_changes:
changes.sort(key=lambda c: (c[2], c[0]))
event_changes.sort(key=lambda c: (c[2], c[0]))
user_changes.sort(key=lambda c: (c[2], c[0]))
all_changes = changes + event_changes + user_changes
if len(all_changes) == 1:
co = HTML_PROCESSOR.from_markdown(
"%%1 changed the level for **%s**: %s%s " % (
all_changes[0][0],
lvl(all_changes[0][1]).lower(),
lvl(all_changes[0][2]).lower(),
),
inline = True,
)
else:
co = HTML_PROCESSOR.from_markdown("\n".join([
"%1 changed the room's permissions",
"",
"Change | Previous | Current ",
"--- | --- | ---",
*[
f"{name} | {lvl(old)} | {lvl(now)}"
for name, old, now in all_changes
],
]))
else:
co = "%1 didn't change the room's permissions"
await self.client.register_nio_event(room, ev, content=co)
async def process_room_member_event(
self, room: nio.MatrixRoom, ev: nio.RoomMemberEvent,
) -> Optional[Tuple[TypeSpecifier, str]]:
"""Return a `TypeSpecifier` and string describing a member event.
Matrix member events can represent many actions:
a user joined the room, a user banned another, a user changed their
display name, etc.
"""
if ev.prev_content == ev.content:
return None
prev = ev.prev_content
now = ev.content
membership = ev.membership
prev_membership = ev.prev_membership
ev_date = datetime.fromtimestamp(ev.server_timestamp / 1000)
member_change = TypeSpecifier.MembershipChange
# Membership changes
if not prev or membership != prev_membership:
if not self.client.backend.settings.Chat.show_membership_events:
return None
reason = escape(
f", reason: {now['reason']}" if now.get("reason") else "",
)
if membership == "join":
return (
member_change,
"%1 accepted their invitation"
if prev and prev_membership == "invite" else
"%1 joined the room",
)
if membership == "invite":
return (member_change, "%1 invited %2 to the room")
if membership == "leave":
if ev.state_key == ev.sender:
return (
member_change,
f"%1 declined their invitation{reason}"
if prev and prev_membership == "invite" else
f"%1 left the room{reason}",
)
return (
member_change,
f"%1 withdrew %2's invitation{reason}"
if prev and prev_membership == "invite" else
f"%1 unbanned %2 from the room{reason}"
if prev and prev_membership == "ban" else
f"%1 kicked %2 out from the room{reason}",
)
if membership == "ban":
return (member_change, f"%1 banned %2 from the room{reason}")
# Profile changes
changed = []
if prev and now.get("avatar_url") != prev.get("avatar_url"):
changed.append("profile picture") # TODO: <img>s
if prev and now.get("displayname") != prev.get("displayname"):
changed.append('display name from "{}" to "{}"'.format(
escape(prev.get("displayname") or ev.state_key),
escape(now.get("displayname") or ev.state_key),
))
if changed:
# Update our account profile if the event is newer than last update
if ev.state_key == self.user_id:
account = self.models["accounts"][self.user_id]
if account.profile_updated < ev_date:
account.set_fields(
profile_updated = ev_date,
display_name = now.get("displayname") or "",
avatar_url = now.get("avatar_url") or "",
)
if not self.client.backend.settings.Chat.show_profile_changes:
return None
return (
TypeSpecifier.ProfileChange,
"%1 changed their {}".format(" and ".join(changed)),
)
async def onRoomAvatarEvent( async def onRoomAvatarEvent(
self, room: nio.MatrixRoom, ev: nio.RoomAvatarEvent, self, room: nio.MatrixRoom, ev: nio.RoomAvatarEvent,

View File

@ -14,6 +14,7 @@ import xml.etree.cElementTree as xml_etree
from concurrent.futures import ProcessPoolExecutor from concurrent.futures import ProcessPoolExecutor
from contextlib import suppress from contextlib import suppress
from datetime import date, datetime, time, timedelta from datetime import date, datetime, time, timedelta
from difflib import SequenceMatcher
from enum import Enum from enum import Enum
from enum import auto as autostr from enum import auto as autostr
from pathlib import Path from pathlib import Path
@ -206,6 +207,28 @@ def strip_html_tags(text: str) -> str:
return re.sub(r"<\/?[^>]+(>|$)", "", text) return re.sub(r"<\/?[^>]+(>|$)", "", text)
def remove_reply(text: str):
return re.sub(r"<mx-reply.*?>.*?<\/mx-reply>", "", text)
def diff_body(a: str, b: str):
sm = SequenceMatcher(None, remove_reply(a), remove_reply(b))
output = []
for opcode, a0, a1, b0, b1 in sm.get_opcodes():
if opcode == "equal":
output.append(sm.a[a0:a1])
elif opcode == "insert":
output.append(f"<ins>{sm.b[b0:b1]}</ins>")
elif opcode == "delete":
output.append(f"<del>{sm.a[a0:a1]}</del>")
elif opcode == "replace":
output.append(f"<del>{sm.a[a0:a1]}</del>")
output.append(f"<ins>{sm.b[b0:b1]}</ins>")
else:
raise RuntimeError(f"unexpected opcode: {opcode}")
return "".join(output)
def serialize_value_for_qml( def serialize_value_for_qml(
value: Any, json_list_dicts: bool = False, reject_unknown: bool = False, value: Any, json_list_dicts: bool = False, reject_unknown: bool = False,
) -> Any: ) -> Any:

View File

@ -35,7 +35,9 @@ TextEdit {
focus: false focus: false
selectByMouse: true selectByMouse: true
onLinkActivated: if (enableLinkActivation && link !== '#state-text') onLinkActivated: if (enableLinkActivation
&& link !== '#state-text'
&& link !== '#replaced-text')
Qt.openUrlExternally(link) Qt.openUrlExternally(link)
MouseArea { MouseArea {

View File

@ -28,8 +28,17 @@ HPage {
HTabButton { text: qsTr("Security") } HTabButton { text: qsTr("Security") }
} }
General { userId: page.userId } General {
Notifications { userId: page.userId } userId: page.userId
Security { userId: page.userId } implicitWidth: 0
}
Notifications {
userId: page.userId
implicitWidth: 0
}
Security {
userId: page.userId
implicitWidth: 0
}
} }
} }

View File

@ -132,6 +132,8 @@ HFlickableColumnPage {
// Layout.preferredWidth: 256 * theme.uiScale // Layout.preferredWidth: 256 * theme.uiScale
Layout.preferredHeight: width Layout.preferredHeight: width
HoverHandler { id: overlayHover }
Rectangle { Rectangle {
anchors.fill: parent anchors.fill: parent
z: 10 z: 10
@ -154,8 +156,6 @@ HFlickableColumnPage {
Behavior on opacity { HNumberAnimation {} } Behavior on opacity { HNumberAnimation {} }
Behavior on color { HColorAnimation {} } Behavior on color { HColorAnimation {} }
HoverHandler { id: overlayHover }
MouseArea { MouseArea {
anchors.fill: parent anchors.fill: parent
enabled: ready && account.presence !== "offline" enabled: ready && account.presence !== "offline"

View File

@ -49,6 +49,18 @@ HRowLayout {
">" ">"
) + "</font></font></a>" ) + "</font></font></a>"
readonly property var reactions: model.reactions
readonly property var contentHistory: model.content_history
readonly property string replacedText:
`<a href="#replaced-text" style="text-decoration: none">` +
`<font size=${theme.fontSize.small}px><font ` + (
model.replaced ?
`color="${theme.chat.message.readCounter}">&nbsp;🖉` : // U+1F589
">"
) + "</font></font></a>"
readonly property bool pureMedia: ! contentText && linksRepeater.count readonly property bool pureMedia: ! contentText && linksRepeater.count
readonly property bool hoveredSelectable: contentHover.hovered readonly property bool hoveredSelectable: contentHover.hovered
@ -123,6 +135,13 @@ HRowLayout {
id: contentLabel id: contentLabel
visible: ! pureMedia visible: ! pureMedia
enableLinkActivation: ! eventList.selectedCount enableLinkActivation: ! eventList.selectedCount
onLinkActivated:
if(link === "#replaced-text") window.makePopup(
"Popups/MessageReplaceHistoryPopup.qml",
{
contentHistory: contentHistory
},
)
selectByMouse: selectByMouse:
eventList.selectedCount <= 1 && eventList.selectedCount <= 1 &&
@ -163,6 +182,7 @@ HRowLayout {
timeText + timeText +
"</font>" + "</font>" +
replacedText +
stateText stateText
transform: Translate { x: xOffset } transform: Translate { x: xOffset }
@ -298,6 +318,8 @@ HRowLayout {
linksRepeater.summedWidth + linksRepeater.summedWidth +
(pureMedia ? 0 : parent.leftPadding + parent.rightPadding), (pureMedia ? 0 : parent.leftPadding + parent.rightPadding),
reactionsRow.width
) )
height: contentColumn.height height: contentColumn.height
radius: theme.chat.message.radius radius: theme.chat.message.radius
@ -361,6 +383,94 @@ HRowLayout {
Layout.preferredHeight: item ? item.height : -1 Layout.preferredHeight: item ? item.height : -1
} }
} }
Row {
id: reactionsRow
spacing: 10
bottomPadding: 7
leftPadding: 10
rightPadding: 10
Layout.alignment: onRight ? Qt.AlignRight : Qt.AlignLeft
Repeater {
id: reactionsRepeater
model: {
const reactions = Object.entries(
JSON.parse(eventDelegate.currentModel.reactions));
return reactions;
}
Rectangle {
id: reactionItem
required property var modelData
readonly property var icon: modelData[0]
readonly property var hint: modelData[1]["hint"]
readonly property var users: modelData[1]["users"]
width: reactionContent.width
height: theme.fontSize.normal + 10
radius: width / 2
color: theme.colors.strongBackground
border.color: theme.colors.accentBackground
border.width: 1
Row {
id: reactionContent
spacing: 5
topPadding: 3
leftPadding: 10
rightPadding: 10
Text {
id: reactionIcon
color: theme.colors.brightText
font.pixelSize: theme.fontSize.normal
font.family: theme.fontFamily.sans
text: parent.parent.icon
}
Text {
id: reactionCounter
color: theme.colors.brightText
font.pixelSize: theme.fontSize.normal
font.family: theme.fontFamily.sans
text: parent.parent.users.length
}
}
MouseArea {
id: reactionItemMouseArea
anchors.fill: parent
onEntered: { reactionTooltip.visible = true }
onExited: { reactionTooltip.visible = false }
hoverEnabled: true
}
HToolTip {
id: reactionTooltip
visible: false
label.textFormat: HLabel.StyledText
text: {
const members =
ModelStore.get(chat.userId, chat.roomId, "members")
const lines = [parent.hint]
for (const userId of parent.users) {
const member = members.find(userId)
const by = utils.coloredNameHtml(
member ? member.display_name: userId, userId,
)
lines.push(qsTr("%1").arg(by))
}
return lines.join("<br>")
}
}
}
}
}
} }
HSpacer {} HSpacer {}

View File

@ -72,20 +72,26 @@ HColumnLayout {
eventList.toggleCheck(model.index) eventList.toggleCheck(model.index)
} }
visible: !model.hidden
width: eventList.width - eventList.leftMargin - eventList.rightMargin width: eventList.width - eventList.leftMargin - eventList.rightMargin
// Needed because of eventList's MouseArea which steals the // Needed because of eventList's MouseArea which steals the
// HSelectableLabel's MouseArea hover events // HSelectableLabel's MouseArea hover events
onCursorShapeChanged: eventList.cursorShape = cursorShape onCursorShapeChanged: eventList.cursorShape = cursorShape
Component.onCompleted: if (model.fetch_profile) Component.onCompleted: {
fetchProfilesFutureId = py.callClientCoro( if (model.fetch_profile)
chat.userId, fetchProfilesFutureId = py.callClientCoro(
"get_event_profiles", chat.userId,
[chat.roomId, model.id], "get_event_profiles",
// The if avoids segfault if eventDelegate is already destroyed [chat.roomId, model.id],
() => { if (eventDelegate) fetchProfilesFutureId = "" } // The if avoids segfault if eventDelegate is already destroyed
) () => { if (eventDelegate) fetchProfilesFutureId = "" }
)
// Workaround for hiding messages of certain types
if (!eventDelegate.visible)
eventDelegate.height = 0
}
Component.onDestruction: Component.onDestruction:
if (fetchProfilesFutureId) py.cancelCoro(fetchProfilesFutureId) if (fetchProfilesFutureId) py.cancelCoro(fetchProfilesFutureId)

View File

@ -266,10 +266,28 @@ Rectangle {
highlightRangeMode = previous highlightRangeMode = previous
} }
function focusPreviousVisibleMessage() {
incrementCurrentIndex()
let lastIndex = -1
while ( currentIndex != lastIndex && model.get(currentIndex).hidden ) {
lastIndex = currentIndex
incrementCurrentIndex()
}
}
function focusPreviousMessage() { function focusPreviousMessage() {
currentIndex === -1 && visibleEnd.y < contentHeight - height / 4 ? currentIndex === -1 && visibleEnd.y < contentHeight - height / 4 ?
focusCenterMessage() : focusCenterMessage() :
incrementCurrentIndex() focusPreviousVisibleMessage()
}
function focusNextVisibleMessage() {
decrementCurrentIndex()
while ( currentIndex > -1 && model.get(currentIndex).hidden ) {
if ( currentIndex === 0 )
currentIndex = -1;
decrementCurrentIndex()
}
} }
function focusNextMessage() { function focusNextMessage() {
@ -279,7 +297,7 @@ Rectangle {
eventList.currentIndex === 0 ? eventList.currentIndex === 0 ?
eventList.currentIndex = -1 : eventList.currentIndex = -1 :
decrementCurrentIndex() focusNextVisibleMessage()
} }
function copySelectedDelegates() { function copySelectedDelegates() {
@ -332,7 +350,7 @@ Rectangle {
} }
function canCombine(item, itemAfter) { function canCombine(item, itemAfter) {
if (! item || ! itemAfter) return false if (! item || ! itemAfter || item.hidden) return false
return Boolean( return Boolean(
! canTalkBreak(item, itemAfter) && ! canTalkBreak(item, itemAfter) &&

View File

@ -0,0 +1,260 @@
// Copyright Mirage authors & contributors <https://github.com/mirukana/mirage>
// SPDX-License-Identifier: LGPL-3.0-or-later
import QtQuick 2.12
import QtQuick.Layouts 1.12
import "../../../Base"
import "../../.."
HRowLayout {
id: historyContent
readonly property var mentions: []
readonly property string mentionsCSS: {
const lines = []
for (const [name, link] of mentions) {
if (! link.match(/^https?:\/\/matrix.to\/#\/@.+/)) continue
lines.push(
`.mention[data-mention='${utils.escapeHtml(name)}'] ` +
`{ color: ${utils.nameColor(name)} }`
)
}
return "<style type='text/css'>" + lines.join("\n") + "</style>"
}
readonly property string diffCSS: {
const lines = [
"del { background-color: #f8d7da; color: #721c24; text-decoration: line-through; }",
"ins { background-color: #d4edda; color: #155724; text-decoration: underline; }",
]
return "<style type='text/css'>" + lines.join("\n") + "</style>"
}
readonly property string senderText: ""
property string contentText: model.content_diff
readonly property string timeText: utils.formatTime(model.date, false)
readonly property bool pureMedia: false
readonly property bool hoveredSelectable: contentHover.hovered
readonly property string hoveredLink:
linksRepeater.lastHovered && linksRepeater.lastHovered.hovered ?
linksRepeater.lastHovered.mediaUrl :
contentLabel.hoveredLink
readonly property alias contentLabel: contentLabel
readonly property int xOffset: 0
readonly property int maxMessageWidth:
contentText.includes("<pre>") || contentText.includes("<table>") ?
-1 :
window.settings.Chat.max_messages_line_length < 0 ?
-1 :
Math.ceil(
mainUI.fontMetrics.averageCharacterWidth *
window.settings.Chat.max_messages_line_length
)
readonly property alias selectedText: contentLabel.selectedPlainText
spacing: theme.chat.message.horizontalSpacing
layoutDirection: Qt.LeftToRight
HColumnLayout {
id: contentColumn
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
HSelectableLabel {
id: contentLabel
visible: ! pureMedia
enableLinkActivation: ! historyList.selectedCount
selectByMouse:
historyList.selectedCount <= 1 &&
historyDelegate.checked &&
textSelectionBlocker.point.scenePosition === Qt.point(0, 0)
topPadding: theme.chat.message.verticalSpacing
bottomPadding: topPadding
leftPadding: historyContent.spacing
rightPadding: leftPadding
color: theme.chat.message.body
font.italic: false
wrapMode: TextEdit.Wrap
textFormat: Text.RichText
text:
// CSS
theme.chat.message.styleInclude + mentionsCSS + diffCSS +
// Sender name & message body
(
compact && contentText.match(/^\s*<(p|h[1-6])>/) ?
contentText.replace(
/(^\s*<(p|h[1-6])>)/, "$1" + senderText,
) :
senderText + contentText
) +
// Time
// For some reason, if there's only one space,
// times will be on their own lines most of the time.
" " +
`<font size=${theme.fontSize.small}px ` +
`color=${theme.chat.message.date}>` +
timeText +
"</font>"
transform: Translate { x: xOffset }
Layout.maximumWidth: historyContent.maxMessageWidth
Layout.fillWidth: true
onSelectedTextChanged: if (selectedPlainText) {
historyList.delegateWithSelectedText = model.id
historyList.selectedText = selectedPlainText
} else if (historyList.delegateWithSelectedText === model.id) {
historyList.delegateWithSelectedText = ""
historyList.selectedText = ""
}
Connections {
target: historyList
onCheckedChanged: contentLabel.deselect()
onDelegateWithSelectedTextChanged: {
if (historyList.delegateWithSelectedText !== model.id)
contentLabel.deselect()
}
}
HoverHandler { id: contentHover }
PointHandler {
id: mousePointHandler
property bool checkedNow: false
acceptedButtons: Qt.LeftButton
acceptedModifiers: Qt.NoModifier
acceptedPointerTypes:
PointerDevice.GenericPointer | PointerDevice.Eraser
onActiveChanged: {
if (active &&
! historyDelegate.checked &&
(! parent.hoveredLink ||
! parent.enableLinkActivation)) {
historyList.check(model.index)
checkedNow = true
}
if (! active && historyDelegate.checked) {
checkedNow ?
checkedNow = false :
historyList.uncheck(model.index)
}
}
}
PointHandler {
id: mouseShiftPointHandler
acceptedButtons: Qt.LeftButton
acceptedModifiers: Qt.ShiftModifier
acceptedPointerTypes:
PointerDevice.GenericPointer | PointerDevice.Eraser
onActiveChanged: {
if (active &&
! historyDelegate.checked &&
(! parent.hoveredLink ||
! parent.enableLinkActivation)) {
historyList.checkFromLastToHere(model.index)
}
}
}
TapHandler {
id: touchTapHandler
acceptedButtons: Qt.LeftButton
acceptedPointerTypes: PointerDevice.Finger | PointerDevice.Pen
onTapped:
if (! parent.hoveredLink || ! parent.enableLinkActivation)
historyDelegate.toggleChecked()
}
TapHandler {
id: textSelectionBlocker
acceptedPointerTypes: PointerDevice.Finger | PointerDevice.Pen
}
Rectangle {
id: contentBackground
width: Math.max(
parent.paintedWidth +
parent.leftPadding + parent.rightPadding,
linksRepeater.summedWidth +
(pureMedia ? 0 : parent.leftPadding + parent.rightPadding),
)
height: contentColumn.height
radius: theme.chat.message.radius
z: -100
color: historyDelegate.checked &&
! contentLabel.selectedPlainText &&
! mousePointHandler.active &&
! mouseShiftPointHandler.active ?
theme.chat.message.checkedBackground :
theme.chat.message.background
}
}
HRepeater {
id: linksRepeater
property EventMediaLoader lastHovered: null
model: {
const links = historyDelegate.currentModel.links
if (historyDelegate.currentModel.media_url)
links.push(historyDelegate.currentModel.media_url)
return links
}
EventMediaLoader {
singleMediaInfo: historyDelegate.currentModel
mediaUrl: modelData
showSender: pureMedia ? senderText : ""
showDate: pureMedia ? timeText : ""
showLocalEcho: pureMedia && (
singleMediaInfo.is_local_echo ||
singleMediaInfo.read_by_count
) ? stateText : ""
transform: Translate { x: xOffset }
onHoveredChanged: if (hovered) linksRepeater.lastHovered = this
Layout.bottomMargin: pureMedia ? 0 : contentLabel.bottomPadding
Layout.leftMargin: pureMedia ? 0 : historyContent.spacing
Layout.rightMargin: pureMedia ? 0 : historyContent.spacing
Layout.preferredWidth: item ? item.width : -1
Layout.preferredHeight: item ? item.height : -1
}
}
}
HSpacer {}
}

View File

@ -0,0 +1,93 @@
// Copyright Mirage authors & contributors <https://github.com/mirukana/mirage>
// SPDX-License-Identifier: LGPL-3.0-or-later
import QtQuick 2.12
import QtQuick.Layouts 1.12
import Clipboard 0.1
import "../../.."
import "../../../Base"
HColumnLayout {
id: historyDelegate
// Remember timeline goes from newest message at index 0 to oldest
readonly property var previousModel: historyList.model.get(model.index + 1)
readonly property var nextModel: historyList.model.get(model.index - 1)
readonly property QtObject currentModel: model
readonly property bool isFocused: model.index === historyList.currentIndex
readonly property bool compact: window.settings.General.compact
readonly property bool checked: model.id in historyList.checked
readonly property bool isOwn: true
readonly property bool isRedacted: false
readonly property bool onRight: ! historyList.ownEventsOnLeft && isOwn
readonly property bool combine: false
readonly property bool talkBreak: false
readonly property bool dayBreak:
model.index === 0 ? true : historyList.canDayBreak(previousModel, model)
readonly property bool hideNameLine: true
readonly property int cursorShape:
historyContent.hoveredLink ? Qt.PointingHandCursor :
historyContent.hoveredSelectable ? Qt.IBeamCursor :
Qt.ArrowCursor
readonly property int separationSpacing: theme.spacing * (
dayBreak ? 4 :
talkBreak ? 6 :
combine && compact ? 0.25 :
combine ? 0.5 :
compact ? 1 :
2
)
readonly property alias historyContent: historyContent
function toggleChecked() {
historyList.toggleCheck(model.index)
}
width: historyList.width - historyList.leftMargin - historyList.rightMargin
// Needed because of historyList's MouseArea which steals the
// HSelectableLabel's MouseArea hover events
onCursorShapeChanged: historyList.cursorShape = cursorShape
ListView.onRemove: historyList.uncheck(model.id)
DelegateTransitionFixer {}
Item {
Layout.fillWidth: true
visible: model.index !== 0
Layout.preferredHeight: separationSpacing
}
DayBreak {
visible: dayBreak
Layout.fillWidth: true
Layout.minimumWidth: parent.width
Layout.bottomMargin: separationSpacing
}
HistoryContent {
id: historyContent
Layout.fillWidth: true
}
TapHandler {
acceptedButtons: Qt.LeftButton
acceptedModifiers: Qt.NoModifier
onTapped: toggleChecked()
}
TapHandler {
acceptedButtons: Qt.LeftButton
acceptedModifiers: Qt.ShiftModifier
onTapped: historyList.checkFromLastToHere(model.index)
}
}

View File

@ -0,0 +1,206 @@
// Copyright Mirage authors & contributors <https://github.com/mirukana/mirage>
// SPDX-License-Identifier: LGPL-3.0-or-later
import QtQuick 2.12
import QtQuick.Layouts 1.12
import QtQuick.Window 2.12
import Clipboard 0.1
import "../../.."
import "../../../Base"
import "../../../PythonBridge"
import "../../../ShortcutBundles"
Rectangle {
readonly property alias historyList: historyList
color: theme.chat.eventList.background
HShortcut {
sequences: window.settings.Keys.Messages.unfocus_or_deselect
onActivated: {
historyList.selectedCount ?
historyList.checked = {} :
historyList.currentIndex = -1
}
}
HShortcut {
sequences: window.settings.Keys.Messages.previous
onActivated: historyList.focusPreviousMessage()
}
HShortcut {
sequences: window.settings.Keys.Messages.next
onActivated: historyList.focusNextMessage()
}
HShortcut {
active: historyList.currentItem
sequences: window.settings.Keys.Messages.select
onActivated: historyList.toggleCheck(historyList.currentIndex)
}
HShortcut {
active: historyList.currentItem
sequences: window.settings.Keys.Messages.select_until_here
onActivated:
historyList.checkFromLastToHere(historyList.currentIndex)
}
HShortcut {
sequences: window.settings.Keys.Messages.open_links_files
onActivated: {
const indice =
historyList.getFocusedOrSelectedOrLastMediaEvents(true)
for (const i of Array.from(indice).sort().reverse()) {
const event = historyList.model.get(i)
for (const url of JSON.parse(event.links)) {
utils.getLinkType(url) === Utils.Media.Image ?
historyList.openImageViewer(event, url) :
Qt.openUrlExternally(url)
}
}
}
}
HShortcut {
sequences: window.settings.Keys.Messages.open_links_files_externally
onActivated: {
const indice =
historyList.getFocusedOrSelectedOrLastMediaEvents(true)
for (const i of Array.from(indice).sort().reverse()) {
const event = historyList.model.get(i)
for (const url of JSON.parse(event.links))
Qt.openUrlExternally(url)
}
}
}
HListView {
id: historyList
property bool ownEventsOnLeft: false
property string delegateWithSelectedText: ""
property string selectedText: ""
property bool showFocusedSeenTooltips: false
property alias cursorShape: cursorShapeArea.cursorShape
function focusCenterMessage() {
const previous = highlightRangeMode
highlightRangeMode = HListView.NoHighlightRange
currentIndex = indexAt(0, contentY + height / 2)
highlightRangeMode = previous
}
function focusPreviousMessage() {
currentIndex === -1 && visibleEnd.y < contentHeight - height / 4 ?
focusCenterMessage() :
incrementCurrentIndex()
}
function focusNextMessage() {
currentIndex === -1 && visibleEnd.y < contentHeight - height / 4 ?
focusCenterMessage() :
historyList.currentIndex === 0 ?
historyList.currentIndex = -1 :
decrementCurrentIndex()
}
function copySelectedDelegates() {
if (historyList.selectedText) {
Clipboard.text = historyList.selectedText
return
}
if (! historyList.selectedCount && historyList.currentIndex !== -1) {
const model = historyList.model.get(historyList.currentIndex)
const source = JSON.parse(model.source)
Clipboard.text =
model.media_http_url &&
utils.isEmptyObject(JSON.parse(model.media_crypt_dict)) ?
model.media_http_url :
"body" in source ?
source.body :
utils.stripHtmlTags(utils.processedEventText(model))
return
}
const contents = []
for (const model of historyList.getSortedChecked()) {
const source = JSON.parse(model.source)
contents.push(
model.media_http_url &&
utils.isEmptyObject(JSON.parse(model.media_crypt_dict)) ?
model.media_http_url :
"body" in source ?
source.body :
utils.stripHtmlTags(utils.processedEventText(model))
)
}
Clipboard.text = contents.join("\n\n")
}
function canDayBreak(item, itemAfter) {
if (! item || ! itemAfter || ! item.date || ! itemAfter.date)
return false
return item.date.getDate() !== itemAfter.date.getDate()
}
function getFocusedOrSelectedOrLastMediaEvents(acceptLinks=false) {
if (historyList.selectedCount) return historyList.checkedIndice
if (historyList.currentIndex !== -1) return [historyList.currentIndex]
// Find most recent event that's a media or contains links
for (let i = 0; i < historyList.model.count && i <= 1000; i++) {
const ev = historyList.model.get(i)
const links = JSON.parse(ev.links)
if (ev.media_url || (acceptLinks && links.length)) return [i]
}
}
anchors.fill: parent
clip: true
keyNavigationWraps: false
leftMargin: theme.spacing
rightMargin: theme.spacing
topMargin: theme.spacing
bottomMargin: theme.spacing
// model: ModelStore.get(chat.userRoomId[0], chat.userRoomId[1], "events")
model: []
delegate: HistoryDelegate {}
highlight: Rectangle {
color: theme.chat.message.focusedHighlight
opacity: theme.chat.message.focusedHighlightOpacity
}
MouseArea {
id: cursorShapeArea
anchors.fill: parent
acceptedButtons: Qt.NoButton
}
}
}

View File

@ -0,0 +1,44 @@
// Copyright Mirage authors & contributors <https://github.com/mirukana/mirage>
// SPDX-License-Identifier: LGPL-3.0-or-later
import QtQuick 2.12
import QtQuick.Controls 2.12
import QtQuick.Layouts 1.12
import "../Base"
import "../Base/Buttons"
import "../Pages/Chat/Timeline"
HColumnPopup {
id: popup
contentWidthLimit:
window.settings.Chat.max_messages_line_length < 0 ?
theme.controls.popup.defaultWidth * 2 :
Math.ceil(
mainUI.fontMetrics.averageCharacterWidth *
window.settings.Chat.max_messages_line_length
)
property var contentHistory
page.footer: AutoDirectionLayout {
CancelButton {
id: cancelButton
onClicked: popup.close()
}
}
onOpened: cancelButton.forceActiveFocus()
SummaryLabel {
text: qsTr("Message History")
textFormat: Text.StyledText
}
HistoryList {
id: historyList
historyList.model: contentHistory
height: 400
Layout.fillWidth: true
Layout.fillHeight: true
}
}

View File

@ -11,13 +11,13 @@ QtObject {
property bool keyboardFlicking: false property bool keyboardFlicking: false
readonly property var imageExtensions: [ readonly property var imageExtensions: [
"bmp", "gif", "jpg", "jpeg", "png", "pbm", "pgm", "ppm", "xbm", "xpm", "bmp", "gif", "jpg", "jpeg", "png", "pbm", "pgm", "ppm", "xbm", "xpm",
"tiff", "webp", "svg", "tiff", "webp", "svg",
] ]
readonly property var videoExtensions: [ readonly property var videoExtensions: [
"3gp", "avi", "flv", "m4p", "m4v", "mkv", "mov", "mp4", "3gp", "avi", "flv", "m4p", "m4v", "mkv", "mov", "mp4",
"mpeg", "mpg", "ogv", "qt", "vob", "webm", "wmv", "yuv", "mpeg", "mpg", "ogv", "qt", "vob", "webm", "wmv", "yuv",
] ]
readonly property var audioExtensions: [ readonly property var audioExtensions: [
@ -214,6 +214,31 @@ QtObject {
const unknownMsg = type === "RoomMessageUnknown" const unknownMsg = type === "RoomMessageUnknown"
const sender = coloredNameHtml(ev.sender_name, ev.sender_id) const sender = coloredNameHtml(ev.sender_name, ev.sender_id)
if (ev.type_specifier === "Reaction") {
let name = coloredNameHtml(
ev.sender_name, ev.sender_id, "", true,
)
let reaction = ev.content
return qsTr(
`<font color="${theme.chat.message.noticeBody}">` +
name + ": " + reaction +
"</font>"
)
}
if (ev.type_specifier === "ReactionRedaction") {
let name = coloredNameHtml(
ev.sender_name, ev.sender_id, "", true,
)
let reaction = ev.content
return qsTr(
`<font color="${theme.chat.message.noticeBody}">` +
name + " removed a reaction" +
"</font>"
)
}
if (type === "RoomMessageEmote") if (type === "RoomMessageEmote")
return ev.content.match(/^\s*<(p|h[1-6])>/) ? return ev.content.match(/^\s*<(p|h[1-6])>/) ?
ev.content.replace(/(^\s*<(p|h[1-6])>)/, `$1${sender} `) : ev.content.replace(/(^\s*<(p|h[1-6])>)/, `$1${sender} `) :

View File

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