adds reactions

This commit is contained in:
gridtime 2023-12-08 09:44:52 +01:00
parent 565508b217
commit f5691fd8be
No known key found for this signature in database
GPG Key ID: FB6ACC7A1C9182F8
10 changed files with 268 additions and 33 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

@ -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

@ -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
@ -225,6 +226,9 @@ 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]]] = {}
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()
@ -359,7 +363,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 +381,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(
@ -2421,6 +2425,49 @@ 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_nio_event( async def register_nio_event(
self, self,
@ -2498,6 +2545,19 @@ class MatrixClient(nio.AsyncClient):
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
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)

View File

@ -28,6 +28,8 @@ class TypeSpecifier(AutoStrEnum):
Unset = auto() Unset = auto()
ProfileChange = auto() ProfileChange = auto()
MembershipChange = auto() MembershipChange = auto()
Reaction = auto()
ReactionRedaction = auto()
class PingStatus(AutoStrEnum): class PingStatus(AutoStrEnum):
@ -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,8 @@ 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)
type_specifier: TypeSpecifier = TypeSpecifier.Unset type_specifier: TypeSpecifier = TypeSpecifier.Unset
target_id: str = "" target_id: str = ""

View File

@ -242,6 +242,12 @@ class NioCallbacks:
await self.onRoomMessageMedia(room, ev) 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( async def onRedactionEvent(
self, room: nio.MatrixRoom, ev: nio.RedactionEvent, self, room: nio.MatrixRoom, ev: nio.RedactionEvent,
) -> None: ) -> None:
@ -260,12 +266,35 @@ class NioCallbacks:
await self.client.register_nio_room(room) await self.client.register_nio_room(room)
return return
event_type = event.source.source.get("type")
if not event_type == "m.reaction":
event.source.source["content"] = {} event.source.source["content"] = {}
event.source.source["unsigned"] = { event.source.source["unsigned"] = {
"redacted_by": ev.event_id, "redacted_by": ev.event_id,
"redacted_because": ev.source, "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( await self.onRedactedEvent(
room, room,
nio.RedactedEvent.from_dict(event.source.source), nio.RedactedEvent.from_dict(event.source.source),

View File

@ -49,6 +49,8 @@ HRowLayout {
">" ">"
) + "</font></font></a>" ) + "</font></font></a>"
readonly property var reactions: model.reactions
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
@ -298,6 +300,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 +365,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,13 +72,15 @@ 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: {
if (model.fetch_profile)
fetchProfilesFutureId = py.callClientCoro( fetchProfilesFutureId = py.callClientCoro(
chat.userId, chat.userId,
"get_event_profiles", "get_event_profiles",
@ -86,6 +88,10 @@ HColumnLayout {
// The if avoids segfault if eventDelegate is already destroyed // The if avoids segfault if eventDelegate is already destroyed
() => { if (eventDelegate) fetchProfilesFutureId = "" } () => { 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,26 @@ 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 ) {
decrementCurrentIndex()
}
} }
function focusNextMessage() { function focusNextMessage() {
@ -279,7 +295,7 @@ Rectangle {
eventList.currentIndex === 0 ? eventList.currentIndex === 0 ?
eventList.currentIndex = -1 : eventList.currentIndex = -1 :
decrementCurrentIndex() focusNextVisibleMessage()
} }
function copySelectedDelegates() { function copySelectedDelegates() {
@ -332,7 +348,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

@ -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} `) :