Daniel Brötzmann pushed to branch archive_cache at gajim / gajim
Commits: 0e17e748 by wurstsalat at 2022-08-24T20:23:34+02:00 chore: Update DOAP file
- - - - - 8a6b117a by wurstsalat at 2022-08-24T20:48:16+02:00 imprv: MessageActionsBox: Disable encryption button if MUC is public
Fixes #10959
- - - - - f9a3eaad by wurstsalat at 2022-08-26T17:38:27+02:00 fix: Chatstates: Don’t sent chatstate delayed to ourself
- - - - - 63c362f5 by wurstsalat at 2022-08-26T23:29:54+02:00 imprv: ChatList: Always show when switching workspace
Fixes #11108
- - - - - 2cf04a23 by Daniel Brötzmann at 2022-08-27T11:09:45+00:00 feat: Add drag and drop support for pinned chats
- - - - - 5125e2c0 by wurstsalat at 2022-08-27T14:06:16+02:00 chore: Control: Remove obsolete code
- - - - - 8431f218 by wurstsalat at 2022-08-27T14:08:08+02:00 fix: Preview: Sanitize filename from disallowed chars
Fixes #11105, #10752
- - - - - ae53799c by lovetox at 2022-08-28T17:45:14+02:00 new: Add event storage
- - - - - 88f324b5 by wurstsalat at 2022-08-28T17:45:14+02:00 Add more events
- - - - - 3f3f2484 by wurstsalat at 2022-08-28T17:45:34+02:00 Add more events 2
- - - - -
26 changed files:
- data/gajim.doap - gajim/common/app.py - gajim/common/application.py - gajim/common/events.py - gajim/common/helpers.py - gajim/common/modules/chatstates.py - gajim/common/modules/contacts.py - gajim/common/modules/muc.py - gajim/common/preview_helpers.py - gajim/common/setting_values.py - gajim/common/settings.py - gajim/common/storage/base.py - + gajim/common/storage/events.py - gajim/data/gui/message_actions_box.ui - gajim/gtk/chat_banner.py - gajim/gtk/chat_list.py - gajim/gtk/chat_list_stack.py - gajim/gtk/chat_page.py - gajim/gtk/chat_stack.py - gajim/gtk/control.py - gajim/gtk/conversation/view.py - gajim/gtk/groupchat_nick_completion.py - gajim/gtk/groupchat_roster.py - gajim/gtk/main.py - gajim/gtk/message_actions_box.py - + test/no_gui/test_sanitize_filename.py
Changes:
===================================== data/gajim.doap ===================================== @@ -170,8 +170,9 @@ <implements> xmpp:SupportedXep <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0071.html%22/%3E - xmpp:statuscomplete</xmpp:status> + xmpp:statusremoved</xmpp:status> xmpp:version1.4</xmpp:version> + xmpp:since1.4.0</xmpp:since> </xmpp:SupportedXep> </implements> <implements> @@ -343,8 +344,9 @@ <implements> xmpp:SupportedXep <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0174.html%22/%3E - xmpp:statuscomplete</xmpp:status> + xmpp:statusremoved</xmpp:status> xmpp:version2.0</xmpp:version> + xmpp:since1.4.0</xmpp:since> </xmpp:SupportedXep> </implements> <implements> @@ -673,7 +675,7 @@ xmpp:SupportedXep <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0441.html%22/%3E xmpp:statuscomplete</xmpp:status> - xmpp:version0.2</xmpp:version> + xmpp:version0.2.0</xmpp:version> </xmpp:SupportedXep> </implements> <implements>
===================================== gajim/common/app.py ===================================== @@ -59,6 +59,7 @@ from gajim.gui.application import GajimApplication from gajim.common.storage.cache import CacheStorage from gajim.common.storage.archive import MessageArchiveStorage + from gajim.common.storage.events import EventStorage from gajim.common.cert_store import CertificateStore from gajim.common.call_manager import CallManager from gajim.common.preview import PreviewManager @@ -88,6 +89,7 @@ class Storage: def __init__(self): self.cache: CacheStorage = None self.archive: MessageArchiveStorage = None + self.events: EventStorage = None
storage = Storage()
===================================== gajim/common/application.py ===================================== @@ -44,6 +44,7 @@ from gajim.common.events import GajimUpdateAvailable from gajim.common.client import Client from gajim.common.helpers import make_http_request +from gajim.common.storage.events import EventStorage from gajim.common.task_manager import TaskManager from gajim.common.settings import Settings from gajim.common.settings import LegacyConfig @@ -74,6 +75,9 @@ def _init_core(self) -> None: app.storage.cache = CacheStorage() app.storage.cache.init()
+ app.storage.events = EventStorage() + app.storage.events.init() + app.storage.archive = MessageArchiveStorage() app.storage.archive.init()
===================================== gajim/common/events.py ===================================== @@ -32,7 +32,10 @@ from nbxmpp.structs import LocationData from nbxmpp.structs import RosterItem from nbxmpp.structs import TuneData +from nbxmpp.const import Affiliation from nbxmpp.const import InviteType +from nbxmpp.const import Role +from nbxmpp.const import StatusCode
from gajim.common.file_props import FileProp from gajim.common.const import JingleState @@ -676,3 +679,100 @@ class AllowGajimUpdateCheck(ApplicationEvent): class GajimUpdateAvailable(ApplicationEvent): name: str = field(init=False, default='gajim-update-available') version: str + + +@dataclass +class MUCNicknameChanged(ApplicationEvent): + name: str = field(init=False, default='muc-nickname-changed') + is_self: bool + new_name: str + old_name: str + timestamp: float + + +@dataclass +class MUCRoomConfigChanged(ApplicationEvent): + name: str = field(init=False, default='muc-room-config-changed') + timestamp: float + status_codes: set[StatusCode] + + +@dataclass +class MUCRoomConfigFinished(ApplicationEvent): + name: str = field(init=False, default='muc-room-config-finished') + timestamp: float + + +@dataclass +class MUCRoomPresenceError(ApplicationEvent): + name: str = field(init=False, default='muc-room-presence-error') + timestamp: float + error: Any + + +@dataclass +class MUCRoomKicked(ApplicationEvent): + name: str = field(init=False, default='muc-room-kicked') + timestamp: float + status_codes: Optional[set[StatusCode]] + reason: Optional[str] + actor: Optional[str] + + +@dataclass +class MUCRoomDestroyed(ApplicationEvent): + name: str = field(init=False, default='muc-room-destroyed') + timestamp: float + reason: Optional[str] + alternate: Optional[JID] + + +@dataclass +class MUCUserJoined(ApplicationEvent): + name: str = field(init=False, default='muc-user-joined') + timestamp: float + is_self: bool + nick: str + status_codes: Optional[set[StatusCode]] + + +@dataclass +class MUCUserLeft(ApplicationEvent): + name: str = field(init=False, default='muc-user-left') + timestamp: float + nick: str + status_codes: Optional[set[StatusCode]] + reason: Optional[str] + actor: Optional[str] + + +@dataclass +class MUCUserRoleChanged(ApplicationEvent): + name: str = field(init=False, default='muc-user-role-changed') + timestamp: float + is_self: bool + nick: str + role: Role + reason: Optional[str] + actor: Optional[str] + + +@dataclass +class MUCUserAffiliationChanged(ApplicationEvent): + name: str = field(init=False, default='muc-user-affiliation-changed') + timestamp: float + is_self: bool + nick: str + affiliation: Affiliation + reason: Optional[str] + actor: Optional[str] + + +@dataclass +class MUCUserStatusShowChanged(ApplicationEvent): + name: str = field(init=False, default='muc-user-status-show-changed') + timestamp: float + is_self: bool + nick: str + status: str + show_value: str
===================================== gajim/common/helpers.py ===================================== @@ -37,7 +37,6 @@ import re import os import subprocess -import base64 import hashlib import shlex import socket @@ -58,10 +57,10 @@ from datetime import timedelta from urllib.parse import unquote from urllib.parse import urlparse -from encodings.punycode import punycode_encode from functools import wraps from pathlib import Path from packaging.version import Version as V +import unicodedata
from nbxmpp.namespaces import Namespace from nbxmpp.const import Role @@ -358,28 +357,40 @@ def get_file_path_from_dnd_dropped_uri(uri: str) -> str:
def sanitize_filename(filename: str) -> str: ''' - Make sure the filename we will write does contain only acceptable and latin - characters, and is not too long (in that case hash it) + Sanitize filename of elements not allowed on Windows + https://docs.microsoft.com/en-us/windows/win32/fileio/naming-a-file + Limit filename length to 50 chars on all systems ''' - # 48 is the limit - if len(filename) > 48: - hash_ = hashlib.md5(filename.encode('utf-8')) - filename = base64.b64encode(hash_.digest()).decode('utf-8') - - # make it latin chars only - filename = punycode_encode(filename).decode('utf-8') - filename = filename.replace('/', '_') - if os.name == 'nt': - filename = filename.replace('?', '_')\ - .replace(':', '_')\ - .replace('\', '_')\ - .replace('"', "'")\ - .replace('|', '_')\ - .replace('*', '_')\ - .replace('<', '_')\ - .replace('>', '_') - - return filename + if sys.platform == 'win32': + blacklist = ['\', '/', ':', '*', '?', '"', '<', '>', '|', '\0'] + reserved_filenames = [ + 'CON', 'PRN', 'AUX', 'NUL', 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', + 'COM6', 'COM7', 'COM8', 'COM9', 'LPT1', 'LPT2', 'LPT3', 'LPT4', + 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9', + ] + filename = ''.join(char for char in filename if char not in blacklist) + + filename = ''.join(char for char in filename if 31 < ord(char)) + + filename = unicodedata.normalize('NFKD', filename) + filename = filename.rstrip('. ') + filename = filename.strip() + + if all(char == '.' for char in filename): + filename = f'__{filename}' + if filename.upper() in reserved_filenames: + filename = f'__{filename}' + if len(filename) == 0: + filename = '__' + + extension = Path(filename).suffix[:10] + filename = Path(filename).stem + final_length = 50 - len(extension) + + # Many Filesystems have a limit on filename length: keep it short + filename = filename[:final_length] + + return f'{filename}{extension}'
def get_contact_dict_for_account(account: str) -> dict[str, types.BareContact]:
===================================== gajim/common/modules/chatstates.py ===================================== @@ -253,6 +253,11 @@ def set_chatstate_delayed(self, # Used when we go from Composing -> Active after deleting all text # from the Textview. We delay the Active state because maybe the # User starts writing again. + + # Don’t send chatstates to ourself + if self._con.get_own_jid().bare_match(contact.jid): + return + self.remove_delay_timeout(contact) self._delay_timeout_ids[contact.jid] = GLib.timeout_add_seconds( 2, self.set_chatstate, contact, state)
===================================== gajim/common/modules/contacts.py ===================================== @@ -15,11 +15,14 @@ from __future__ import annotations
from typing import Any +from typing import cast from typing import Iterator from typing import Optional from typing import Union from typing import overload
+import time + import cairo from nbxmpp.const import Affiliation from nbxmpp.const import Chatstate @@ -29,9 +32,12 @@ from nbxmpp.structs import DiscoInfo from nbxmpp.structs import LocationData from nbxmpp.structs import TuneData +from nbxmpp.structs import MessageProperties from nbxmpp.structs import MucSubject +from nbxmpp.structs import PresenceProperties
from gajim.common import app +from gajim.common import events from gajim.common import types from gajim.common.const import PresenceShowExt from gajim.common.const import SimpleClientState @@ -671,6 +677,15 @@ def is_irc(self) -> bool: return False return disco_info.is_irc
+ @property + def encryption_available(self) -> bool: + disco_info = self.get_disco() + if disco_info is None: + return True + + return (disco_info.muc_is_members_only and + disco_info.muc_is_nonanonymous) + def get_config_value(self, field_name: str) -> Any: disco_info = self.get_disco() assert disco_info is not None @@ -933,12 +948,31 @@ def update_presence(self,
if not self._presence.available and presence.available: self._presence = presence - self.notify('user-joined', *args) + + properties = cast(MessageProperties, args[0]) + event = events.MUCUserJoined( + timestamp=time.time(), + is_self=properties.is_muc_self_presence, + nick=self.name, + status_codes=properties.muc_status_codes) + + app.storage.events.store(self, event) + self.notify('user-joined', event) return
if not presence.available: self._presence = presence - self.notify('user-left', *args) + + properties = cast(MessageProperties, args[0]) + event = events.MUCUserLeft( + timestamp=time.time(), + nick=self.name, + status_codes=properties.muc_status_codes, + reason=properties.muc_user.reason, + actor=properties.muc_user.actor) + + app.storage.events.store(self, event) + self.notify('user-left', event) return
signals: list[str] = [] @@ -953,8 +987,39 @@ def update_presence(self, signals.append('user-status-show-changed')
self._presence = presence + for signal in signals: - self.notify(signal, *args) + if signal == 'user-affiliation-changed': + properties = cast(PresenceProperties, args[0]) + event = events.MUCUserAffiliationChanged( + timestamp=time.time(), + is_self=properties.is_muc_self_presence, + nick=self.name, + affiliation=self.affiliation, + reason=properties.muc_user.reason, + actor=properties.muc_user.actor) + + if signal == 'user-role-changed': + properties = cast(PresenceProperties, args[0]) + event = events.MUCUserRoleChanged( + timestamp=time.time(), + is_self=properties.is_muc_self_presence, + nick=self.name, + role=self.role, + reason=properties.muc_user.reason, + actor=properties.muc_user.actor) + + if signal == 'user-status-show-changed': + properties = cast(PresenceProperties, args[0]) + event = events.MUCUserStatusShowChanged( + timestamp=time.time(), + is_self=properties.is_muc_self_presence, + nick=self.name, + status=properties.status, + show_value=properties.show.value) + + app.storage.events.store(self, event) + self.notify(signal, event)
def update_avatar(self, *args: Any) -> None: app.app.avatar_storage.invalidate_cache(self._jid)
===================================== gajim/common/modules/muc.py ===================================== @@ -44,6 +44,7 @@ from gi.repository import GLib
from gajim.common import app +from gajim.common import events from gajim.common import helpers from gajim.common import types from gajim.common.const import ClientState, KindConstant @@ -456,7 +457,11 @@ def _on_disco_result_after_config(self, task: Task) -> None: self._log.info('Configuration finished: %s', jid)
room = self._get_contact(jid.bare) - room.notify('room-config-finished') + event = events.MUCRoomConfigFinished(timestamp=time.time()) + + assert isinstance(room, GroupchatContact) + app.storage.events.store(room, event) + room.notify('room-config-finished', event)
def update_presence(self) -> None: mucs = self._get_mucs_with_state([MUCJoinedState.JOINED, @@ -536,6 +541,11 @@ def _on_error_presence(self, self._set_muc_state(room_jid, MUCJoinedState.NOT_JOINED)
else: + event = events.MUCRoomPresenceError( + timestamp=time.time(), + error=properties.error) + assert isinstance(room, GroupchatContact) + app.storage.events.store(room, event) room.notify('room-presence-error', properties)
def _on_muc_user_presence(self, @@ -560,7 +570,15 @@ def _on_muc_user_presence(self, self._set_muc_state(room_jid, MUCJoinedState.NOT_JOINED) self._con.get_module('Bookmarks').remove(room_jid) room.set_not_joined() - room.notify('room-destroyed', properties) + + event = events.MUCRoomDestroyed( + timestamp=time.time(), + reason=properties.muc_destroyed.reason, + alternate=properties.muc_destroyed.alternate) + assert isinstance(room, GroupchatContact) + app.storage.events.store(room, event) + + room.notify('room-destroyed', event) return
if properties.is_nickname_changed: @@ -586,10 +604,16 @@ def _on_muc_user_presence(self, presence = self._process_user_presence(properties) occupant.update_presence(presence, properties)
- room.notify('user-nickname-changed', - occupant, - new_occupant, - properties) + event = events.MUCNicknameChanged( + is_self=properties.is_self_presence, + new_name=new_occupant.name, + old_name=occupant.name, + timestamp=time.time()) + + assert isinstance(room, GroupchatContact) + app.storage.events.store(room, event) + + room.notify('user-nickname-changed', event, occupant, new_occupant) return
is_joined = self._is_user_joined(properties.jid) @@ -616,7 +640,16 @@ def _on_muc_user_presence(self, if properties.is_muc_self_presence and properties.is_kicked: self._set_muc_state(room_jid, MUCJoinedState.NOT_JOINED) room.set_not_joined() - room.notify('room-kicked', properties) + + event = events.MUCRoomKicked( + timestamp=time.time(), + status_codes=properties.muc_status_codes, + reason=properties.muc_user.reason, + actor=properties.muc_user.actor) + assert isinstance(room, GroupchatContact) + app.storage.events.store(room, event) + room.notify('room-kicked', event) + status_codes = properties.muc_status_codes or [] if StatusCode.REMOVED_SERVICE_SHUTDOWN in status_codes: self._start_rejoin_timeout(room_jid) @@ -889,12 +922,21 @@ def _on_config_change(self, if not properties.is_muc_config_change: return
- room_jid = str(properties.muc_jid) self._log.info('Received config change: %s %s', - room_jid, properties.muc_status_codes) + properties.muc_jid, properties.muc_status_codes)
- room = self._get_contact(room_jid) - room.notify('room-config-changed', properties) + assert properties.muc_status_codes is not None + event = events.MUCRoomConfigChanged( + timestamp=time.time(), + status_codes=properties.muc_status_codes) + + assert properties.muc_jid is not None + room = self._get_contact(properties.muc_jid) + + assert isinstance(room, GroupchatContact) + app.storage.events.store(room, event) + + room.notify('room-config-changed', event)
raise nbxmpp.NodeProcessed
===================================== gajim/common/preview_helpers.py ===================================== @@ -45,6 +45,7 @@ from cryptography.hazmat.primitives.ciphers import algorithms from cryptography.hazmat.primitives.ciphers.modes import GCM
+from gajim.common.helpers import sanitize_filename from gajim.common.i18n import _
log = logging.getLogger('gajim.c.preview_helpers') @@ -308,12 +309,7 @@ def get_image_paths(uri: str, web_stem = path.stem extension = path.suffix
- if len(web_stem) > 90: - # Many Filesystems have a limit on filename length - # Most have 255, some encrypted ones only 143 - # We add around 50 chars for the hash, - # so the filename should not exceed 90 - web_stem = web_stem[:90] + web_stem = sanitize_filename(web_stem)
name_hash = hashlib.sha1(str(uri).encode()).hexdigest()
===================================== gajim/common/setting_values.py ===================================== @@ -506,24 +506,33 @@ class _ACCOUNT_DEFAULT: 'name', ]
-AllWorkspaceSettings = Literal[StringWorkspaceSettings, 'open_chats'] +AllWorkspaceSettings = Literal[StringWorkspaceSettings, 'chats']
-OpenChatSettingT = list[tuple[str, JID, str, bool]] -AllWorkspaceSettingsT = Union[str, OpenChatSettingT] + +class OpenChatSettingDetails(TypedDict): + account: str + jid: JID + type: str + pinned: bool + position: int + + +OpenChatsSettingT = list[OpenChatSettingDetails] +AllWorkspaceSettingsT = Union[str, OpenChatsSettingT]
class WorkspaceSettings(TypedDict): name: str color: str avatar_sha: str - open_chats: OpenChatSettingT + chats: OpenChatsSettingT
WORKSPACE_SETTINGS: WorkspaceSettings = { 'name': _('My Workspace'), 'color': '', 'avatar_sha': '', - 'open_chats': [], + 'chats': [], }
===================================== gajim/common/settings.py ===================================== @@ -56,7 +56,7 @@ from gajim.common.setting_values import StringGroupChatSettings from gajim.common.setting_values import AllWorkspaceSettings from gajim.common.setting_values import AllWorkspaceSettingsT -from gajim.common.setting_values import OpenChatSettingT +from gajim.common.setting_values import OpenChatsSettingT from gajim.common.setting_values import StringWorkspaceSettings from gajim.common.setting_values import ACCOUNT_SETTINGS from gajim.common.setting_values import PROXY_SETTINGS @@ -82,6 +82,8 @@
log = logging.getLogger('gajim.c.settings')
+CURRENT_USER_VERSION = 2 + CREATE_SQL = ''' CREATE TABLE settings ( name TEXT UNIQUE, @@ -100,10 +102,11 @@ INSERT INTO settings(name, settings) VALUES ('plugins', '{}'); INSERT INTO settings(name, settings) VALUES ('workspaces', '%s');
- PRAGMA user_version=1; + PRAGMA user_version=%s; ''' % (json.dumps(STATUS_PRESET_EXAMPLES), json.dumps(PROXY_EXAMPLES), - json.dumps(INITAL_WORKSPACE)) + json.dumps(INITAL_WORKSPACE), + CURRENT_USER_VERSION)
_SignalCallable = Callable[[Any, str, Optional[str], Optional[JID]], Any] @@ -217,6 +220,7 @@ def init(self) -> None: self._commit() self._migrate_database() self._load_app_overrides() + self._commit()
@staticmethod def _setup_installation_defaults() -> None: @@ -320,6 +324,23 @@ def _migrate(self) -> None: self._settings['workspaces'] = INITAL_WORKSPACE self._set_user_version(1)
+ if version < 2: + # Migrate open chats to new key and format + for workspace in self._settings['workspaces'].values(): + open_chats = [] + for open_chat in workspace.get('open_chats', []): + account, jid, type_, pinned = open_chat + open_chats.append({'account': account, + 'jid': jid, + 'type': type_, + 'pinned': pinned, + 'position': -1}) + + workspace['chats'] = open_chats + workspace.pop('open_chats', None) + + self._set_user_version(2) + def _migrate_old_config(self) -> None: config_file = configpaths.get('CONFIG_FILE') if not config_file.exists(): @@ -1180,8 +1201,8 @@ def set_workspace_setting(self, @overload def set_workspace_setting(self, workspace_id: str, - setting: Literal['open_chats'], - value: list[tuple[str, JID, str, bool]] + setting: Literal['chats'], + value: OpenChatsSettingT ) -> None: ...
@@ -1207,8 +1228,8 @@ def set_workspace_setting(self, @overload def get_workspace_setting(self, workspace_id: str, - setting: Literal['open_chats'] - ) -> OpenChatSettingT: + setting: Literal['chats'] + ) -> OpenChatsSettingT: ...
@overload
===================================== gajim/common/storage/base.py ===================================== @@ -98,6 +98,13 @@ def _adapt_disco_info(disco_info: DiscoInfo) -> str: sqlite3.register_adapter(DiscoInfo, _adapt_disco_info)
+def _convert_json(json_string: bytes) -> dict[str, Any]: + return json.loads(json_string) + + +sqlite3.register_converter('JSON', _convert_json) + + class Encoder(json.JSONEncoder): def default(self, o: Any) -> Any: if isinstance(o, set):
===================================== gajim/common/storage/events.py ===================================== @@ -0,0 +1,131 @@ +# This file is part of Gajim. +# +# Gajim is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published +# by the Free Software Foundation; version 3 only. +# +# Gajim is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Gajim. If not, see http://www.gnu.org/licenses/. + +from __future__ import annotations +import dataclasses + +from typing import Any +from typing import NamedTuple + +import json +import sqlite3 +import logging +from collections import namedtuple + +from nbxmpp.protocol import JID + +from gajim.common import events +from gajim.common.storage.base import SqliteStorage +from gajim.common.types import ChatContactT + + +EVENTS_SQL_STATEMENT = ''' + CREATE TABLE events ( + account TEXT, + jid TEXT, + event TEXT, + timestamp REAL, + data TEXT); + + CREATE INDEX idx_account_jid ON events(account, jid); + + PRAGMA user_version=1; + ''' + +EVENT_CLASSES: dict[str, Any] = { + 'muc-nickname-changed': events.MUCNicknameChanged, + 'muc-room-config-changed': events.MUCRoomConfigChanged, + 'muc-room-config-finished': events.MUCRoomConfigFinished, + 'muc-room-presence-error': events.MUCRoomPresenceError, + 'muc-room-kicked': events.MUCRoomKicked, + 'muc-room-destroyed': events.MUCRoomDestroyed, + 'muc-user-joined': events.MUCUserJoined, + 'muc-user-left': events.MUCUserLeft, + 'muc-user-role-changed': events.MUCUserRoleChanged, + 'muc-user-affiliation-changed': events.MUCUserAffiliationChanged, + 'muc-user-status-show-changed': events.MUCUserStatusShowChanged, +} + +log = logging.getLogger('gajim.c.storage.events') + + +class EventRow(NamedTuple): + account: str + jid: JID + event: str + timestamp: float + data: dict[str, Any] + + +class EventStorage(SqliteStorage): + def __init__(self): + SqliteStorage.__init__(self, + log, + None, + EVENTS_SQL_STATEMENT) + + def init(self, **kwargs: Any) -> None: + SqliteStorage.init(self, + detect_types=sqlite3.PARSE_COLNAMES) + self._con.row_factory = self._namedtuple_factory + + @staticmethod + def _namedtuple_factory(cursor: sqlite3.Cursor, + row: tuple[Any, ...]) -> NamedTuple: + + assert cursor.description is not None + fields = [col[0] for col in cursor.description] + Row = namedtuple('Row', fields) # type: ignore + return Row(*row) + + def _migrate(self) -> None: + pass + + def store(self, + contact: ChatContactT, + event: Any + ) -> None: + + event_dict = dataclasses.asdict(event) + name = event_dict.pop('name') + timestamp = event_dict.pop('timestamp') + + insert_sql = ''' + INSERT INTO events(account, jid, event, timestamp, data) + VALUES(?, ?, ?, ?, ?)''' + + self._con.execute(insert_sql, (contact.account, + contact.jid, + name, + timestamp, + json.dumps(event_dict))) + + def load(self, + contact: ChatContactT, + before: bool, + timestamp: float + ) -> list[EventRow]: + + time_operator = '<' if before else '>' + + insert_sql = ''' + SELECT account, jid, event, timestamp, data as "data [JSON]" + FROM events WHERE account=? AND jid=? AND timestamp %s ? + ''' % time_operator + + for row in self._con.execute(insert_sql, (contact.account, + contact.jid, + timestamp)).fetchall(): + event_class = EVENT_CLASSES[row.event] + yield event_class(**row.data, timestamp=row.timestamp)
===================================== gajim/data/gui/message_actions_box.ui ===================================== @@ -1,5 +1,5 @@ <?xml version="1.0" encoding="UTF-8"?> -<!-- Generated with glade 3.38.2 --> +<!-- Generated with glade 3.40.0 --> <interface> <requires lib="gtk+" version="3.24"/> <object class="GtkBox" id="box"> @@ -91,7 +91,6 @@ <property name="visible">True</property> <property name="can-focus">True</property> <property name="receives-default">True</property> - <property name="tooltip-text" translatable="yes">Choose encryption</property> <property name="relief">none</property> <child> <object class="GtkImage" id="encryption_image">
===================================== gajim/gtk/chat_banner.py ===================================== @@ -21,8 +21,6 @@
import cairo
-from nbxmpp.structs import PresenceProperties - from gajim.common import app from gajim.common import ged from gajim.common import types @@ -184,13 +182,7 @@ def _on_muc_state_changed(self, if contact.is_joined: self._update_content()
- def _on_user_role_changed(self, - _contact: GroupchatContact, - _signal_name: str, - user_contact: GroupchatParticipant, - properties: PresenceProperties - ) -> None: - + def _on_user_role_changed(self, *args: Any) -> None: self._update_content()
def _on_user_state_changed(self, *args: Any) -> None:
===================================== gajim/gtk/chat_list.py ===================================== @@ -26,6 +26,7 @@ from gi.repository import Gio from gi.repository import Gdk from gi.repository import GLib +from gi.repository import GObject from gi.repository import Gtk import cairo
@@ -47,6 +48,7 @@ from gajim.common.helpers import AdditionalDataDict from gajim.common.preview_helpers import filename_from_uri from gajim.common.preview_helpers import guess_simple_file_type +from gajim.common.setting_values import OpenChatsSettingT from gajim.common.types import ChatContactT from gajim.common.types import OneOnOneContactT from gajim.common.modules.contacts import BareContact @@ -65,6 +67,13 @@
class ChatList(Gtk.ListBox, EventHelper): + + __gsignals__ = { + 'chat-order-changed': (GObject.SignalFlags.RUN_LAST, + None, + ()), + } + def __init__(self, workspace_id: str) -> None: Gtk.ListBox.__init__(self) EventHelper.__init__(self) @@ -94,12 +103,16 @@ def __init__(self, workspace_id: str) -> None: entries, Gdk.DragAction.MOVE)
+ self._drag_row: Optional[ChatRow] = None + self._chat_order: list[ChatRow] = [] + self.register_events([ ('account-enabled', ged.GUI2, self._on_account_changed), ('account-disabled', ged.GUI2, self._on_account_changed), ('bookmarks-received', ged.GUI1, self._on_bookmarks_received), ])
+ self.connect('drag-data-received', self._on_drag_data_received) self.connect('destroy', self._on_destroy)
self._timer_id = GLib.timeout_add_seconds(60, self._update_timer) @@ -146,6 +159,94 @@ def emit_unread_changed(self) -> None: self._workspace_id, count)
+ def _get_row_before(self, row: ChatRow) -> Optional[ChatRow]: + row_before = self.get_row_at_index(row.get_index() - 1) + if row_before is None: + return + return cast(ChatRow, row_before) + + def _get_row_after(self, row: ChatRow) -> Optional[ChatRow]: + row_after = self.get_row_at_index(row.get_index() + 1) + if row_after is None: + return + return cast(ChatRow, row_after) + + def _get_last_row(self) -> ChatRow: + index = len(self.get_children()) - 1 + last_row = self.get_row_at_index(index) + assert last_row is not None + return cast(ChatRow, last_row) + + def set_drag_row(self, row: ChatRow) -> None: + self._drag_row = row + + def _on_drag_data_received(self, + _widget: Gtk.Widget, + _drag_context: Gdk.DragContext, + _x_coord: int, + y_coord: int, + selection_data: Gtk.SelectionData, + _info: int, + _time: int + ) -> None: + + item_type = selection_data.get_data_type().name() + if item_type != 'CHAT_LIST_ITEM': + log.debug('Unknown item type dropped') + return + + assert self._drag_row is not None + + if not self._drag_row.is_pinned: + log.debug('Dropped row is not pinned') + return + + row = cast(ChatRow, self.get_row_at_y(y_coord)) + if row is not None: + alloc = row.get_allocation() + hover_row_y = alloc.y + hover_row_height = alloc.height + + if y_coord < hover_row_y + hover_row_height / 2: + row_before = self._get_row_before(row) + row_after = row + else: + row_before = row + row_after = self._get_row_after(row) + else: + row_before = self._get_last_row() + row_after = None + + if self._drag_row in (row_before, row_after): + log.debug('Dropped row on self') + return + + if row_before is not None and not row_before.is_pinned: + log.debug('Dropped under not pinned row') + return + + self._change_pinned_order(row_before) + + def _change_pinned_order(self, row_before: Optional[ChatRow]) -> None: + assert self._drag_row is not None + + self._chat_order.remove(self._drag_row) + + if row_before is None: + self._chat_order.insert(0, self._drag_row) + else: + offset = 0 + if row_before.position < self._drag_row.position: + offset = 1 + self._chat_order.insert( + row_before.position + offset, self._drag_row) + + for row in self._chat_order: + row.position = self._chat_order.index(row) + + self.emit('chat-order-changed') + self.invalidate_sort() + def _on_destroy(self, _widget: Gtk.Widget) -> None: GLib.source_remove(self._timer_id)
@@ -198,9 +299,11 @@ def _sort_func(self, row1: ChatRow, row2: ChatRow) -> int: log.debug('Mouseover active, don’t sort rows') return 0
- # Don’t sort pinned rows themselves + # Sort pinned rows according to stored order if row1.is_pinned and row2.is_pinned: - return 0 + if row1.position > row2.position: + return 1 + return -1
# Sort pinned rows to top if row1.is_pinned > row2.is_pinned: @@ -250,14 +353,30 @@ def get_chat_type(self, account: str, jid: JID) -> Optional[str]: return row.type return None
- def add_chat(self, account: str, jid: JID, type_: str, - pinned: bool = False) -> None: - if self._chats.get((account, jid)) is not None: + def add_chat(self, + account: str, + jid: JID, + type_: str, + pinned: bool, + position: int + ) -> None: + + key = (account, jid) + if self._chats.get(key) is not None: # Chat is already in the List return
- row = ChatRow(self._workspace_id, account, jid, type_, pinned) - self._chats[(account, jid)] = row + row = ChatRow(self._workspace_id, + account, + jid, + type_, + pinned, + position) + + self._chats[key] = row + if pinned: + self._chat_order.insert(position, row) + self.add(row)
def select_chat(self, account: str, jid: JID) -> None: @@ -332,12 +451,26 @@ def select_chat_number(self, number: int) -> None:
def toggle_chat_pinned(self, account: str, jid: JID) -> None: row = self._chats[(account, jid)] + + if row.is_pinned: + self._chat_order.remove(row) + row.position = -1 + else: + self._chat_order.append(row) + row.position = self._chat_order.index(row) + row.toggle_pinned() self.invalidate_sort()
- def remove_chat(self, account: str, jid: JID, - emit_unread: bool = True) -> None: + def remove_chat(self, + account: str, + jid: JID, + emit_unread: bool = True + ) -> None: + row = self._chats.pop((account, jid)) + if row.is_pinned: + self._chat_order.remove(row) self.remove(row) row.destroy() if emit_unread: @@ -359,10 +492,15 @@ def get_selected_chat(self) -> Optional[ChatRow]: def contains_chat(self, account: str, jid: JID) -> bool: return self._chats.get((account, jid)) is not None
- def get_open_chats(self) -> list[tuple[str, JID, str, bool]]: - open_chats: list[tuple[str, JID, str, bool]] = [] - for key, value in self._chats.items(): - open_chats.append(key + (value.type, value.is_pinned)) + def get_open_chats(self) -> OpenChatsSettingT: + open_chats: OpenChatsSettingT = [] + for key, row in self._chats.items(): + account, jid = key + open_chats.append({'account': account, + 'jid': jid, + 'type': row.type, + 'pinned': row.is_pinned, + 'position': row.position}) return open_chats
def update_time(self) -> None: @@ -536,14 +674,22 @@ def _on_bookmarks_received(self, _event: events.BookmarksReceived) -> None:
class ChatRow(Gtk.ListBoxRow): - def __init__(self, workspace_id: str, account: str, jid: JID, type_: str, - pinned: bool) -> None: + def __init__(self, + workspace_id: str, + account: str, + jid: JID, + type_: str, + pinned: bool, + position: int + ) -> None: + Gtk.ListBoxRow.__init__(self)
self.account = account self.jid = jid self.workspace_id = workspace_id self.type = type_ + self.position = position
self.active_label = ActiveHeader() self.conversations_label = ConversationsHeader() @@ -735,6 +881,10 @@ def _on_drag_begin(self, widget: Gtk.Widget, drag_context: Gdk.DragContext ) -> None: + + chat_list = cast(ChatList, self.get_parent()) + chat_list.set_drag_row(self) + # Use rendered ChatListRow as drag icon alloc = self.get_allocation() surface = cairo.ImageSurface(
===================================== gajim/gtk/chat_list_stack.py ===================================== @@ -14,7 +14,6 @@
from __future__ import annotations
-from typing import Dict from typing import Optional from typing import cast
@@ -66,7 +65,7 @@ def __init__(self, self.set_vexpand(True) self.set_vhomogeneous(False)
- self._chat_lists: Dict[str, ChatList] = {} + self._chat_lists: dict[str, ChatList] = {}
self._last_visible_child_name: str = 'default'
@@ -153,6 +152,7 @@ def _on_search_changed(self, search_entry: Gtk.SearchEntry) -> None: def add_chat_list(self, workspace_id: str) -> ChatList: chat_list = ChatList(workspace_id) chat_list.connect('row-selected', self._on_row_selected) + chat_list.connect('chat-order-changed', self._on_chat_order_changed)
self._chat_lists[workspace_id] = chat_list self.add_named(chat_list, workspace_id) @@ -161,8 +161,10 @@ def add_chat_list(self, workspace_id: str) -> ChatList: def remove_chat_list(self, workspace_id: str) -> None: chat_list = self._chat_lists[workspace_id] self.remove(chat_list) - for account, jid, _type, _pinned in chat_list.get_open_chats(): - self.remove_chat(workspace_id, account, jid) + for open_chat in chat_list.get_open_chats(): + self.remove_chat(workspace_id, + open_chat['account'], + open_chat['jid'])
self._chat_lists.pop(workspace_id) chat_list.destroy() @@ -177,6 +179,12 @@ def _on_row_selected(self,
self.emit('chat-selected', row.workspace_id, row.account, row.jid)
+ def _on_chat_order_changed(self, + chat_list: ChatList + ) -> None: + + self.store_open_chats(chat_list.workspace_id) + def show_chat_list(self, workspace_id: str) -> None: cur_workspace_id = self.get_visible_child_name() if cur_workspace_id == workspace_id: @@ -187,12 +195,19 @@ def show_chat_list(self, workspace_id: str) -> None:
self.set_visible_child_name(workspace_id)
- def add_chat(self, workspace_id: str, account: str, jid: JID, type_: str, - pinned: bool = False) -> None: + def add_chat(self, + workspace_id: str, + account: str, + jid: JID, + type_: str, + pinned: bool, + position: int + ) -> None: + chat_list = self._chat_lists.get(workspace_id) if chat_list is None: chat_list = self.add_chat_list(workspace_id) - chat_list.add_chat(account, jid, type_, pinned) + chat_list.add_chat(account, jid, type_, pinned, position)
def select_chat(self, account: str, jid: JID) -> None: chat_list = self.find_chat(account, jid) @@ -206,7 +221,7 @@ def store_open_chats(self, workspace_id: str) -> None: chat_list = self._chat_lists[workspace_id] open_chats = chat_list.get_open_chats() app.settings.set_workspace_setting( - workspace_id, 'open_chats', open_chats) + workspace_id, 'chats', open_chats)
@structs.actionmethod def _toggle_chat_pinned(self, @@ -235,7 +250,8 @@ def _move_chat_to_workspace(self, current_chatlist.remove_chat(params.account, params.jid)
new_chatlist = self.get_chatlist(workspace_id) - new_chatlist.add_chat(params.account, params.jid, type_) + new_chatlist.add_chat(params.account, params.jid, type_, False, -1) + self.store_open_chats(current_chatlist.workspace_id) self.store_open_chats(workspace_id)
===================================== gajim/gtk/chat_page.py ===================================== @@ -221,6 +221,7 @@ def add_chat_for_workspace(self, jid: JID, type_: str, pinned: bool = False, + position: int = -1, select: bool = False) -> None:
client = app.get_client(account) @@ -240,7 +241,7 @@ def add_chat_for_workspace(self, return
self._chat_list_stack.add_chat(workspace_id, account, jid, type_, - pinned) + pinned, position)
if self._startup_finished: if select: @@ -249,18 +250,20 @@ def add_chat_for_workspace(self,
def load_workspace_chats(self, workspace_id: str) -> None: open_chats = app.settings.get_workspace_setting(workspace_id, - 'open_chats') + 'chats')
active_accounts = app.settings.get_active_accounts() - for account, jid, type_, pinned in open_chats: + for open_chat in open_chats: + account = open_chat['account'] if account not in active_accounts: continue
self.add_chat_for_workspace(workspace_id, account, - jid, - type_, - pinned=pinned) + open_chat['jid'], + open_chat['type'], + pinned=open_chat['pinned'], + position=open_chat['position'])
def is_chat_active(self, account: str, jid: JID) -> bool: return self._chat_list_stack.is_chat_active(account, jid)
===================================== gajim/gtk/chat_stack.py ===================================== @@ -28,7 +28,6 @@ from nbxmpp.errors import StanzaError from nbxmpp.protocol import JID from nbxmpp.structs import MessageProperties -from nbxmpp.structs import PresenceProperties
from gajim.common import app from gajim.common import events @@ -297,23 +296,25 @@ def _on_user_joined(self, contact: GroupchatContact, _signal_name: str, _user_contact: GroupchatParticipant, - _properties: PresenceProperties + _event: events.MUCUserJoined ) -> None: + self._update_group_chat_actions(contact)
def _on_user_role_changed(self, contact: GroupchatContact, _signal_name: str, _user_contact: GroupchatParticipant, - _properties: PresenceProperties + _event: events.MUCUserRoleChanged ) -> None: + self._update_group_chat_actions(contact)
def _on_user_affiliation_changed(self, contact: GroupchatContact, _signal_name: str, _user_contact: GroupchatParticipant, - _properties: PresenceProperties + _event: events.MUCUserAffiliationChanged ) -> None: self._update_group_chat_actions(contact)
===================================== gajim/gtk/control.py ===================================== @@ -19,9 +19,6 @@ from typing import Union from typing import cast
-from collections import defaultdict -from collections import deque -from functools import partial import os import logging import time @@ -105,12 +102,6 @@ def __init__(self) -> None: self._muc_subjects: dict[ types.ChatContactT, tuple[float, MucSubject]] = {}
- # Store 100 info messages per contact on a FIFO basis - self._info_messages: dict[ - types.ChatContactT, - deque[tuple[float, str]]] = defaultdict( - partial(deque, maxlen=100)) # pyright: ignore - self.widget = cast(Gtk.Box, self._ui.get_object('control_box')) self.widget.show_all()
@@ -212,9 +203,6 @@ def switch_contact(self, contact: Union[BareContact, for transfer in transfers: self.add_file_transfer(transfer)
- for timestamp, message in self._info_messages[contact]: - self.add_info_message(message, timestamp) - if isinstance(contact, GroupchatContact): if (app.settings.get('show_subject_on_join') or not contact.is_joining): @@ -515,10 +503,6 @@ def add_info_message(self, timestamp: Optional[float] = None ) -> None:
- if timestamp is None: - assert self._contact is not None - self._info_messages[self._contact].append((time.time(), text)) - self.conversation_view.add_info_message(text, timestamp)
def add_file_transfer(self, transfer: HTTPFileTransfer) -> None: @@ -639,6 +623,40 @@ def _fetch_n_lines_history(self, # if self.conversation_view.reduce_message_count(before): # self._scrolled_view.set_history_complete(before, False)
+ for event in app.storage.events.load(self._contact, before, timestamp): + if isinstance(event, events.MUCUserJoined): + self._process_muc_user_joined(event) + + if isinstance(event, events.MUCUserLeft): + self._process_muc_user_left(event) + + if isinstance(event, events.MUCNicknameChanged): + self._process_muc_nickname_changed(event) + + if isinstance(event, events.MUCRoomKicked): + self._process_muc_room_kicked(event) + + if isinstance(event, events.MUCUserAffiliationChanged): + self._process_muc_user_affiliation_changed(event) + + if isinstance(event, events.MUCUserRoleChanged): + self._process_muc_user_role_changed(event) + + if isinstance(event, events.MUCUserStatusShowChanged): + self._process_muc_user_status_show_changed(event) + + if isinstance(event, events.MUCRoomConfigChanged): + self._process_muc_room_config_changed(event) + + if isinstance(event, events.MUCRoomConfigFinished): + self._process_muc_room_config_finished(event) + + if isinstance(event, events.MUCRoomPresenceError): + self._process_muc_room_presence_error(event) + + if isinstance(event, events.MUCRoomDestroyed): + self._process_muc_room_destroyed(event) + self._scrolled_view.block_signals(False)
def add_messages(self, messages: list[ConversationRow]): @@ -725,150 +743,212 @@ def add_message(self, def _on_user_nickname_changed(self, contact: types.GroupchatContact, _signal_name: str, - old_contact: types.GroupchatParticipant, - new_contact: types.GroupchatParticipant, - properties: PresenceProperties + _old_contact: types.GroupchatParticipant, + _new_contact: types.GroupchatParticipant, + event: events.MUCNicknameChanged ) -> None:
- if properties.is_muc_self_presence: - message = _('You are now known as %s') % new_contact.name + self._process_muc_nickname_changed(event) + + def _process_muc_nickname_changed(self, + event: events.MUCNicknameChanged + ) -> None: + + if event.is_self: + message = _('You are now known as %s') % event.new_name else: message = _('{nick} is now known ' - 'as {new_nick}').format(nick=old_contact.name, - new_nick=new_contact.name) + 'as {new_nick}').format(nick=event.old_name, + new_nick=event.new_name) + self.add_info_message(message, event.timestamp)
- self.add_info_message(message) + def _on_room_kicked(self, + _contact: GroupchatContact, + _signal_name: str, + event: events.MUCRoomKicked + ) -> None:
- def _on_muc_user_status_show_changed(self, - contact: GroupchatContact, - _signal_name: str, - user_contact: GroupchatParticipant, - properties: PresenceProperties - ) -> None: + self._process_muc_room_kicked(event)
- if not contact.settings.get('print_status'): + def _process_muc_room_kicked(self, event: events.MUCRoomKicked) -> None: + status_codes = event.status_codes or [] + + reason = event.reason + reason = '' if reason is None else f': {reason}' + + actor = event.actor + # Group Chat: You have been kicked by Alice + actor = '' if actor is None else _(' by {actor}').format( + actor=actor) + + # Group Chat: We have been removed from the room by Alice: reason + message = _('You have been removed from the ' + 'group chat{actor}{reason}') + + if StatusCode.REMOVED_ERROR in status_codes: + # Handle 333 before 307, some MUCs add both + # Group Chat: Server kicked us because of an server error + message = _('You have left due ' + 'to an error{reason}').format(reason=reason) + + elif StatusCode.REMOVED_KICKED in status_codes: + # Group Chat: We have been kicked by Alice: reason + message = _('You have been ' + 'kicked{actor}{reason}').format(actor=actor, + reason=reason) + + elif StatusCode.REMOVED_BANNED in status_codes: + # Group Chat: We have been banned by Alice: reason + message = _('You have been ' + 'banned{actor}{reason}').format(actor=actor, + reason=reason) + + elif StatusCode.REMOVED_AFFILIATION_CHANGE in status_codes: + # Group Chat: We were removed because of an affiliation change + reason = _(': Affiliation changed') + message = message.format(actor=actor, reason=reason) + + elif StatusCode.REMOVED_NONMEMBER_IN_MEMBERS_ONLY in status_codes: + # Group Chat: Room configuration changed + reason = _(': Group chat configuration changed to members-only') + message = message.format(actor=actor, reason=reason) + + elif StatusCode.REMOVED_SERVICE_SHUTDOWN in status_codes: + # Group Chat: Kicked because of server shutdown + reason = ': System shutdown' + message = message.format(actor=actor, reason=reason) + + else: + # No formatted message available return
- self.conversation_view.add_user_status(user_contact.name, - user_contact.show.value, - user_contact.status) + self.add_info_message(message, event.timestamp)
- def _on_user_status_show_changed(self, - _user_contact: GroupchatParticipant, + def _on_user_affiliation_changed(self, + _contact: GroupchatContact, _signal_name: str, - properties: PresenceProperties + user_contact: GroupchatParticipant, + event: events.MUCUserAffiliationChanged ) -> None:
- nick = properties.muc_nickname - status = properties.status - status = '' if not status else f' - {status}' - assert properties.show is not None - show = helpers.get_uf_show(properties.show.value) + self._process_muc_user_affiliation_changed(event)
- assert isinstance(self.contact, GroupchatParticipant) - if not self.contact.room.settings.get('print_status'): - return + def _process_muc_user_affiliation_changed(self, + event: events.MUCUserAffiliationChanged + ) -> None:
- if properties.is_muc_self_presence: - message = _('You are now {show}{status}').format(show=show, - status=status) + affiliation = helpers.get_uf_affiliation(event.affiliation) + nick = event.name + + reason = event.reason + reason = '' if reason is None else f': {reason}'
+ actor = event.actor + # Group Chat: You have been kicked by Alice + actor = '' if actor is None else _(' by {actor}').format( + actor=actor) + + if event.is_self: + message = _('** Your Affiliation has been set to ' + '{affiliation}{actor}{reason}').format( + affiliation=affiliation, + actor=actor, + reason=reason) else: - message = _('{nick} is now {show}{status}').format(nick=nick, - show=show, - status=status) - self.add_info_message(message) - - def invite(self, invited_jid: JID) -> None: - # TODO: Remove, used by command system - self.client.get_module('MUC').invite( - self.contact.jid, invited_jid) - invited_contact = self.client.get_module('Contacts').get_contact( - invited_jid) - self.add_info_message( - _('%s has been invited to this group chat') % invited_contact.name) + message = _('** Affiliation of {nick} has been set to ' + '{affiliation}{actor}{reason}').format( + nick=nick, + affiliation=affiliation, + actor=actor, + reason=reason)
- def _on_room_voice_request(self, - _contact: GroupchatContact, - _signal_name: str, - properties: MessageProperties - ) -> None: - voice_request = properties.voice_request - assert voice_request is not None + self.add_info_message(message, event.timestamp)
- def on_approve() -> None: - self.client.get_module('MUC').approve_voice_request( - self.contact.jid, voice_request) + def _on_user_role_changed(self, + _contact: GroupchatContact, + _signal_name: str, + user_contact: GroupchatParticipant, + event: events.MUCUserRoleChanged + ) -> None:
- ConfirmationDialog( - _('Voice Request'), - _('Voice Request'), - _('<b>%(nick)s</b> from <b>%(room_name)s</b> requests voice') % { - 'nick': voice_request.nick, 'room_name': self.contact.name}, - [DialogButton.make('Cancel'), - DialogButton.make('Accept', - text=_('_Approve'), - callback=on_approve)], - modal=False).show() + self._process_muc_user_role_changed(event)
- def add_muc_message(self, - text: str, - tim: float, - contact: str = '', - displaymarking: Optional[Displaymarking] = None, - message_id: Optional[str] = None, - stanza_id: Optional[str] = None, - additional_data: Optional[AdditionalDataDict] = None, - ) -> None: + def _process_muc_user_role_changed(self, + event: events.MUCUserRoleChanged + ) -> None:
- assert isinstance(self._contact, GroupchatContact) + role = helpers.get_uf_role(event.role) + nick = event.nick
- if contact == self._contact.nickname: - kind = 'outgoing' + reason = event.reason + reason = '' if reason is None else f': {reason}' + + actor = event.actor + # Group Chat: You have been kicked by Alice + actor = '' if actor is None else _(' by {actor}').format(actor=actor) + + if event.is_self: + message = _('** Your Role has been set to ' + '{role}{actor}{reason}').format(role=role, + actor=actor, + reason=reason) else: - kind = 'incoming' - # muc-specific chatstate + message = _('** Role of {nick} has been set to ' + '{role}{actor}{reason}').format(nick=nick, + role=role, + actor=actor, + reason=reason) + self.add_info_message(message, event.timestamp)
- self._add_message(text, - kind, - contact, - tim, - displaymarking=displaymarking, - message_id=message_id, - stanza_id=stanza_id, - additional_data=additional_data) + def _on_user_status_show_changed(self, + _user_contact: GroupchatParticipant, + _signal_name: str, + event: events.MUCUserStatusShowChanged + ) -> None:
- def _on_room_subject(self, - contact: GroupchatContact, - _signal_name: str, - subject: Optional[MucSubject] - ) -> None: + self._process_muc_user_status_show_changed(event)
- if subject is None: - return + def _process_muc_user_status_show_changed(self, + event: events.MUCUserStatusShowChanged + ) -> None:
- _timestamp, old_subject = self._muc_subjects.get(contact, (None, None)) - if old_subject is not None and old_subject.text == subject.text: - # Probably a rejoin, we already showed that subject + nick = event.nick + status = event.status + status = '' if not status else f' - {status}' + show = helpers.get_uf_show(event.show_value) + + assert isinstance(self.contact, GroupchatParticipant) + if not self.contact.room.settings.get('print_status'): return
- self._muc_subjects[contact] = (time.time(), subject) + if event.is_self: + message = _('You are now {show}{status}').format(show=show, + status=status)
- if (app.settings.get('show_subject_on_join') or - not contact.is_joining): - self.conversation_view.add_muc_subject(subject) + else: + message = _('{nick} is now {show}{status}').format( + nick=nick, + show=show, + status=status) + + self.add_info_message(message, event.timestamp)
def _on_room_config_changed(self, _contact: GroupchatContact, _signal_name: str, - properties: MessageProperties + event: events.MUCRoomConfigChanged ) -> None: - # http://www.xmpp.org/extensions/xep-0045.html#roomconfig-notify
- status_codes = properties.muc_status_codes - assert status_codes is not None + self._process_muc_room_config_changed(event)
+ def _process_muc_room_config_changed(self, + event: events.MUCRoomConfigChanged + ) -> None: + + # http://www.xmpp.org/extensions/xep-0045.html#roomconfig-notify + status_codes = event.status_codes changes: list[str] = [] + if StatusCode.SHOWING_UNAVAILABLE in status_codes: changes.append(_('Group chat now shows unavailable members'))
@@ -882,7 +962,8 @@ def _on_room_config_changed(self, self.client.get_module('Discovery').disco_muc(self.contact.jid)
if StatusCode.CONFIG_ROOM_LOGGING in status_codes: - # Can be a presence (see chg_contact_status in groupchat_control.py) + # Can be a presence + # (see chg_contact_status in groupchat_control.py) changes.append(_('Conversations are stored on the server'))
if StatusCode.CONFIG_NO_ROOM_LOGGING in status_codes: @@ -897,194 +978,127 @@ def _on_room_config_changed(self, if StatusCode.CONFIG_FULL_ANONYMOUS in status_codes: changes.append(_('Group chat is now fully anonymous'))
- for change in changes: - self.add_info_message(change) - - def rejoin(self) -> None: - self.client.get_module('MUC').join(self.contact.jid) - - def _on_user_joined(self, - contact: GroupchatContact, - _signal_name: str, - user_contact: GroupchatParticipant, - properties: PresenceProperties - ) -> None: - - nick = user_contact.name - if not properties.is_muc_self_presence: - if contact.is_joined: - self.conversation_view.add_muc_user_joined(nick) - return - - status_codes = properties.muc_status_codes or [] - - if not contact.is_joined: - # We just joined the room - self.add_info_message(_('You (%s) joined the group chat') % nick) - - if StatusCode.NON_ANONYMOUS in status_codes: - self.add_info_message( - _('Any participant is allowed to see your full XMPP Address')) - - if StatusCode.CONFIG_ROOM_LOGGING in status_codes: - self.add_info_message(_('Conversations are stored on the server')) - - if StatusCode.NICKNAME_MODIFIED in status_codes: - self.add_info_message( - _('The server has assigned or modified your nickname in this ' - 'group chat')) + for message in changes: + self.add_info_message(message, event.timestamp)
def _on_room_config_finished(self, _contact: GroupchatContact, - _signal_name: str + _signal_name: str, + event: events.MUCRoomConfigFinished ) -> None: + self._process_muc_room_config_finished(event) + + def _process_muc_room_config_finished(self, + event: events.MUCRoomConfigFinished + ) -> None: + self.add_info_message(_('A new group chat has been created'))
- def _on_user_affiliation_changed(self, - _contact: GroupchatContact, - _signal_name: str, - user_contact: GroupchatParticipant, - properties: PresenceProperties - ) -> None: - affiliation = helpers.get_uf_affiliation(user_contact.affiliation) - nick = user_contact.name + def _on_room_presence_error(self, + _contact: GroupchatContact, + _signal_name: str, + event: events.MUCRoomPresenceError + ) -> None:
- assert properties.muc_user is not None - reason = properties.muc_user.reason - reason = '' if reason is None else f': {reason}' + self._process_muc_room_presence_error(event)
- actor = properties.muc_user.actor - # Group Chat: You have been kicked by Alice - actor = '' if actor is None else _(' by {actor}').format(actor=actor) + def _process_muc_room_presence_error(self, + event: events.MUCRoomPresenceError + ) -> None:
- if properties.is_muc_self_presence: - message = _('** Your Affiliation has been set to ' - '{affiliation}{actor}{reason}').format( - affiliation=affiliation, - actor=actor, - reason=reason) - else: - message = _('** Affiliation of {nick} has been set to ' - '{affiliation}{actor}{reason}').format( - nick=nick, - affiliation=affiliation, - actor=actor, - reason=reason) + error_message = to_user_string(event.error) + self.add_info_message(_('Error: %s') % error_message)
- self.add_info_message(message) + def _on_room_destroyed(self, + _contact: GroupchatContact, + _signal_name: str, + event: events.MUCRoomDestroyed + ) -> None:
- def _on_user_role_changed(self, - _contact: GroupchatContact, - _signal_name: str, - user_contact: GroupchatParticipant, - properties: PresenceProperties - ) -> None: - role = helpers.get_uf_role(user_contact.role) - nick = user_contact.name + self._process_muc_room_destroyed(event)
- assert properties.muc_user is not None - reason = properties.muc_user.reason + def _process_muc_room_destroyed(self, + event: events.MUCRoomDestroyed + ) -> None: + + reason = event.reason reason = '' if reason is None else f': {reason}'
- actor = properties.muc_user.actor - # Group Chat: You have been kicked by Alice - actor = '' if actor is None else _(' by {actor}').format(actor=actor) + message = _('Group chat has been destroyed')
- if properties.is_muc_self_presence: - message = _('** Your Role has been set to ' - '{role}{actor}{reason}').format(role=role, - actor=actor, - reason=reason) - else: - message = _('** Role of {nick} has been set to ' - '{role}{actor}{reason}').format(nick=nick, - role=role, - actor=actor, - reason=reason) + if event.alternate is not None: + message += '\n' + _('You can join this group chat instead: ' + 'xmpp:%s?join') % str(event.alternate)
- self.add_info_message(message) + self.add_info_message(message, event.timestamp)
- def _on_room_kicked(self, + def _on_user_joined(self, _contact: GroupchatContact, _signal_name: str, - properties: MessageProperties + _user_contact: GroupchatParticipant, + event: events.MUCUserJoined ) -> None: - status_codes = properties.muc_status_codes or []
- assert properties.muc_user is not None - reason = properties.muc_user.reason - reason = '' if reason is None else f': {reason}' + self._process_muc_user_joined(event)
- actor = properties.muc_user.actor - # Group Chat: You have been kicked by Alice - actor = '' if actor is None else _(' by {actor}').format(actor=actor) + def _process_muc_user_joined(self, event: events.MUCUserJoined) -> None: + assert isinstance(self.contact, GroupchatContact)
- # Group Chat: We have been removed from the room by Alice: reason - message = _('You have been removed from the group chat{actor}{reason}') + if not event.is_self: + if self.contact.is_joined: + self.conversation_view.add_muc_user_joined(event.nick) + return
- if StatusCode.REMOVED_ERROR in status_codes: - # Handle 333 before 307, some MUCs add both - # Group Chat: Server kicked us because of an server error - message = _('You have left due ' - 'to an error{reason}').format(reason=reason) - self.add_info_message(message) + status_codes = event.status_codes or []
- elif StatusCode.REMOVED_KICKED in status_codes: - # Group Chat: We have been kicked by Alice: reason - message = _('You have been ' - 'kicked{actor}{reason}').format(actor=actor, - reason=reason) - self.add_info_message(message) + message = None + if not self.contact.is_joined: + # We just joined the room + message = _('You (%s) joined the group chat') % event.nick
- elif StatusCode.REMOVED_BANNED in status_codes: - # Group Chat: We have been banned by Alice: reason - message = _('You have been ' - 'banned{actor}{reason}').format(actor=actor, - reason=reason) - self.add_info_message(message) + if StatusCode.NON_ANONYMOUS in status_codes: + message = _('Any participant is allowed to see your full ' + 'XMPP Address')
- elif StatusCode.REMOVED_AFFILIATION_CHANGE in status_codes: - # Group Chat: We were removed because of an affiliation change - reason = _(': Affiliation changed') - message = message.format(actor=actor, reason=reason) - self.add_info_message(message) + if StatusCode.CONFIG_ROOM_LOGGING in status_codes: + message = _('Conversations are stored on the server')
- elif StatusCode.REMOVED_NONMEMBER_IN_MEMBERS_ONLY in status_codes: - # Group Chat: Room configuration changed - reason = _(': Group chat configuration changed to members-only') - message = message.format(actor=actor, reason=reason) - self.add_info_message(message) + if StatusCode.NICKNAME_MODIFIED in status_codes: + message = _('The server has assigned or modified your ' + 'nickname in this group chat')
- elif StatusCode.REMOVED_SERVICE_SHUTDOWN in status_codes: - # Group Chat: Kicked because of server shutdown - reason = ': System shutdown' - message = message.format(actor=actor, reason=reason) - self.add_info_message(message) + if message is not None: + self.add_info_message(message, event.timestamp)
def _on_user_left(self, _contact: GroupchatContact, _signal_name: str, - user_contact: GroupchatParticipant, - properties: MessageProperties + _user_contact: GroupchatParticipant, + event: events.MUCUserLeft ) -> None: - status_codes = properties.muc_status_codes or [] - nick = user_contact.name
- assert properties.muc_user is not None - reason = properties.muc_user.reason + self._process_muc_user_left(event) + + def _process_muc_user_left(self, event: events.MUCUserLeft) -> None: + status_codes = event.status_codes or [] + nick = event.nick + + reason = event.reason reason = '' if reason is None else f': {reason}'
- actor = properties.muc_user.actor + actor = event.actor # Group Chat: You have been kicked by Alice - actor = '' if actor is None else _(' by {actor}').format(actor=actor) + actor = '' if actor is None else _(' by {actor}').format( + actor=actor)
# Group Chat: We have been removed from the room - message = _('{nick} has been removed from the group chat{by}{reason}') + message = _('{nick} has been removed from the group ' + 'chat{by}{reason}')
if StatusCode.REMOVED_ERROR in status_codes: # Handle 333 before 307, some MUCs add both self.conversation_view.add_muc_user_left( - nick, properties.muc_user.reason, error=True) + nick, event.reason, error=True)
elif StatusCode.REMOVED_KICKED in status_codes: # Group Chat: User was kicked by Alice: reason @@ -1092,7 +1106,6 @@ def _on_user_left(self, 'kicked{actor}{reason}').format(nick=nick, actor=actor, reason=reason) - self.add_info_message(message)
elif StatusCode.REMOVED_BANNED in status_codes: # Group Chat: User was banned by Alice: reason @@ -1100,49 +1113,104 @@ def _on_user_left(self, 'banned{actor}{reason}').format(nick=nick, actor=actor, reason=reason) - self.add_info_message(message)
elif StatusCode.REMOVED_AFFILIATION_CHANGE in status_codes: reason = _(': Affiliation changed') message = message.format(nick=nick, by=actor, reason=reason) - self.add_info_message(message)
elif StatusCode.REMOVED_NONMEMBER_IN_MEMBERS_ONLY in status_codes: reason = _(': Group chat configuration changed to members-only') message = message.format(nick=nick, by=actor, reason=reason) - self.add_info_message(message)
else: - self.conversation_view.add_muc_user_left( - nick, properties.muc_user.reason) + self.conversation_view.add_muc_user_left(nick, event.reason) + return
- def _on_room_presence_error(self, - _contact: GroupchatContact, - _signal_name: str, - properties: PresenceProperties - ) -> None: + self.add_info_message(message, event.timestamp)
- assert properties.error is not None - error_message = to_user_string(properties.error) - self.add_info_message(_('Error: %s') % error_message) + def _on_muc_user_status_show_changed(self, + contact: GroupchatContact, + _signal_name: str, + user_contact: GroupchatParticipant, + properties: PresenceProperties + ) -> None:
- def _on_room_destroyed(self, - _contact: GroupchatContact, - _signal_name: str, - properties: PresenceProperties - ) -> None: + if not contact.settings.get('print_status'): + return
- destroyed = properties.muc_destroyed - assert destroyed is not None + self.conversation_view.add_user_status(user_contact.name, + user_contact.show.value, + user_contact.status)
- reason = destroyed.reason - reason = '' if reason is None else f': {reason}' + def _on_room_voice_request(self, + _contact: GroupchatContact, + _signal_name: str, + properties: MessageProperties + ) -> None: + voice_request = properties.voice_request + assert voice_request is not None
- message = _('Group chat has been destroyed') - self.add_info_message(message) + def on_approve() -> None: + self.client.get_module('MUC').approve_voice_request( + self.contact.jid, voice_request) + + ConfirmationDialog( + _('Voice Request'), + _('Voice Request'), + _('<b>%(nick)s</b> from <b>%(room_name)s</b> requests voice') % { + 'nick': voice_request.nick, 'room_name': self.contact.name}, + [DialogButton.make('Cancel'), + DialogButton.make('Accept', + text=_('_Approve'), + callback=on_approve)], + modal=False).show()
- alternate = destroyed.alternate - if alternate is not None: - join_message = _('You can join this group chat ' - 'instead: xmpp:%s?join') % str(alternate) - self.add_info_message(join_message) + def add_muc_message(self, + text: str, + tim: float, + contact: str = '', + displaymarking: Optional[Displaymarking] = None, + message_id: Optional[str] = None, + stanza_id: Optional[str] = None, + additional_data: Optional[AdditionalDataDict] = None, + ) -> None: + + assert isinstance(self._contact, GroupchatContact) + + if contact == self._contact.nickname: + kind = 'outgoing' + else: + kind = 'incoming' + # muc-specific chatstate + + self._add_message(text, + kind, + contact, + tim, + displaymarking=displaymarking, + message_id=message_id, + stanza_id=stanza_id, + additional_data=additional_data) + + def _on_room_subject(self, + contact: GroupchatContact, + _signal_name: str, + subject: Optional[MucSubject] + ) -> None: + + if subject is None: + return + + _timestamp, old_subject = self._muc_subjects.get(contact, (None, None)) + if old_subject is not None and old_subject.text == subject.text: + # Probably a rejoin, we already showed that subject + return + + self._muc_subjects[contact] = (time.time(), subject) + + if (app.settings.get('show_subject_on_join') or + not contact.is_joining): + self.conversation_view.add_muc_subject(subject) + + def rejoin(self) -> None: + self.client.get_module('MUC').join(self.contact.jid)
===================================== gajim/gtk/conversation/view.py ===================================== @@ -176,7 +176,7 @@ def add_muc_subject(self,
def add_muc_user_left(self, nick: str, - reason: str, + reason: Optional[str], error: bool = False) -> None: assert isinstance(self._contact, GroupchatContact) if not self._contact.settings.get('print_join_left'):
===================================== gajim/gtk/groupchat_nick_completion.py ===================================== @@ -18,7 +18,6 @@
import logging
-from nbxmpp.structs import PresenceProperties
from gi.repository import Gtk from gi.repository import Gdk @@ -27,6 +26,7 @@ from gajim.common import ged from gajim.common import types from gajim.common.events import GcMessageReceived +from gajim.common.events import MUCNicknameChanged from gajim.common.ged import EventHelper from gajim.common.helpers import jid_is_blocked from gajim.common.helpers import message_needs_highlight @@ -76,12 +76,12 @@ def switch_contact(self, contact: types.GroupchatContactT) -> None: def _on_user_nickname_changed(self, _contact: types.GroupchatContact, _signal_name: str, + event: MUCNicknameChanged, old_contact: types.GroupchatParticipant, - new_contact: types.GroupchatParticipant, - properties: PresenceProperties + new_contact: types.GroupchatParticipant ) -> None:
- if properties.is_muc_self_presence: + if event.is_self: return
old_name = old_contact.name
===================================== gajim/gtk/groupchat_roster.py ===================================== @@ -25,7 +25,6 @@ from gi.repository import Gtk from gi.repository import GLib from nbxmpp.const import Affiliation -from nbxmpp.structs import PresenceProperties
from gajim.common import app from gajim.common import ged @@ -36,6 +35,7 @@ from gajim.common.const import AvatarSize from gajim.common.const import StyleAttr from gajim.common.events import ApplicationEvent +from gajim.common.events import MUCNicknameChanged from gajim.common.modules.contacts import GroupchatContact
from .menus import get_groupchat_roster_menu @@ -283,9 +283,9 @@ def _update_contact(self, def _on_user_nickname_changed(self, contact: types.GroupchatContact, _signal_name: str, + _event: MUCNicknameChanged, old_contact: types.GroupchatParticipant, - new_contact: types.GroupchatParticipant, - properties: PresenceProperties + new_contact: types.GroupchatParticipant ) -> None:
self._remove_contact(old_contact)
===================================== gajim/gtk/main.py ===================================== @@ -653,6 +653,13 @@ def activate_workspace(self, workspace_id: str) -> None: self._main_stack.show_chats(workspace_id) self._workspace_side_bar.activate_workspace(workspace_id)
+ # Show chatlist if it is hidden + chat_list_stack = self._chat_page.get_chat_list_stack() + chat_list = chat_list_stack.get_current_chat_list() + if chat_list is not None: + if not chat_list.is_visible(): + self._toggle_chat_list() + def update_workspace(self, workspace_id: str) -> None: self._chat_page.update_workspace(workspace_id) self._workspace_side_bar.update_avatar(workspace_id)
===================================== gajim/gtk/message_actions_box.py ===================================== @@ -219,9 +219,8 @@ def switch_contact(self, contact: ChatContactT) -> None:
self._set_settings_menu(contact)
- encryption = self._contact.settings.get('encryption') - self._set_encryption_state(encryption) - self._set_encryption_details(encryption) + self._update_encryption_button() + self._update_encryption_details_button()
self._set_chatstate(True)
@@ -286,18 +285,6 @@ def _set_chatstate(self, state: bool) -> None: self._client.get_module('Chatstate').set_chatstate( self._contact, Chatstate.INACTIVE)
- def _set_encryption_state(self, state: str) -> None: - action = app.window.get_action('set-encryption') - action.set_state(GLib.Variant('s', state)) - - if state in ('OMEMO', 'OpenPGP', 'PGP'): - icon_name = 'channel-secure-symbolic' - else: - icon_name = 'channel-insecure-symbolic' - - self._ui.encryption_image.set_from_icon_name(icon_name, - Gtk.IconSize.MENU) - def _change_encryption(self, action: Gio.SimpleAction, param: GLib.Variant @@ -319,13 +306,43 @@ def _change_encryption(self, if not plugin.activate_encryption(app.window.get_control()): return
- self._set_encryption_state(new_state) contact = self.get_current_contact() contact.settings.set('encryption', new_state)
- self._set_encryption_details(new_state) + self._update_encryption_button() + self._update_encryption_details_button() + + def _update_encryption_button(self) -> None: + contact = self.get_current_contact() + state = contact.settings.get('encryption') + + action = app.window.get_action('set-encryption') + action.set_state(GLib.Variant('s', state)) + + sensitive = True + tooltip = _('Choose encryption') + + if state in ('OMEMO', 'OpenPGP', 'PGP'): + icon_name = 'channel-secure-symbolic' + else: + icon_name = 'channel-insecure-symbolic' + + if isinstance(self._contact, GroupchatContact): + if not self._contact.encryption_available: + sensitive = False + tooltip = _('This is a public group chat. ' + 'Encryption is not available.') + icon_name = 'channel-insecure-symbolic' + + self._ui.encryption_menu_button.set_sensitive(sensitive) + self._ui.encryption_menu_button.set_tooltip_text(tooltip) + self._ui.encryption_image.set_from_icon_name( + icon_name, Gtk.IconSize.MENU) + + def _update_encryption_details_button(self) -> None: + contact = self.get_current_contact() + state = contact.settings.get('encryption')
- def _set_encryption_details(self, state: str) -> None: encryption_state = {'visible': bool(state), 'enc_type': state, 'authenticated': False} @@ -341,19 +358,22 @@ def _set_encryption_details(self, state: str) -> None:
if authenticated: authenticated_string = _('and authenticated') - self._ui.encryption_details_image.set_from_icon_name( - 'security-high-symbolic', Gtk.IconSize.MENU) + icon_name = 'security-high-symbolic' else: authenticated_string = _('and NOT authenticated') - self._ui.encryption_details_image.set_from_icon_name( - 'security-low-symbolic', Gtk.IconSize.MENU) + icon_name = 'security-low-symbolic'
tooltip = _('%(type)s encryption is active %(authenticated)s.') % { - 'type': enc_type, 'authenticated': authenticated_string} + 'type': enc_type, + 'authenticated': authenticated_string} + + if isinstance(self._contact, GroupchatContact) and visible: + visible = self._contact.encryption_available
- self._ui.encryption_details_button.set_tooltip_text(tooltip) self._ui.encryption_details_button.set_visible(visible) - self._ui.encryption_details_image.set_sensitive(visible) + self._ui.encryption_details_button.set_tooltip_text(tooltip) + self._ui.encryption_details_image.set_from_icon_name( + icon_name, Gtk.IconSize.MENU)
def _on_encryption_details_clicked(self, _button: Gtk.Button) -> None: contact = self.get_current_contact()
===================================== test/no_gui/test_sanitize_filename.py ===================================== @@ -0,0 +1,61 @@ + +import sys +import unittest +from unittest.mock import patch +from gajim.common.helpers import sanitize_filename + + +class SanitizeTest(unittest.TestCase): + '''Tests for the sanitize_filename function.''' + + @patch.object(sys, 'platform', 'win32') + def test_invalid_chars(self): + '''Make sure invalid characters are removed in filenames''' + self.assertEqual(sanitize_filename('A/B/C'), 'ABC') + self.assertEqual(sanitize_filename('A*C.d'), 'AC.d') + self.assertEqual(sanitize_filename('A?C.d'), 'AC.d') + + @patch.object(sys, 'platform', 'win32') + def test_invalid_suffix(self): + '''Dots are not allowed at the end''' + self.assertEqual(sanitize_filename('def.'), 'def') + self.assertEqual(sanitize_filename('def.ghi'), 'def.ghi') + self.assertTrue(sanitize_filename('X' * 1000 + '.').endswith('X')) + + @patch.object(sys, 'platform', 'win32') + def test_reserved_words(self): + '''Make sure reserved Windows words are prefixed''' + self.assertEqual(sanitize_filename('NUL'), '__NUL') + self.assertEqual(sanitize_filename('..'), '__') + + @patch.object(sys, 'platform', 'win32') + def test_long_names(self): + '''Make sure long names are truncated''' + self.assertEqual(len(sanitize_filename('X' * 300)), 50) + self.assertEqual(len(sanitize_filename( + '.'.join(['X' * 100, 'X' * 100, 'X' * 100]))), 50) + self.assertEqual(len(sanitize_filename( + '.'.join(['X' * 300, 'X' * 300, 'X' * 300]))), 50) + self.assertEqual(len(sanitize_filename('.' * 300 + '.txt')), 50) + + @patch.object(sys, 'platform', 'win32') + def test_unicode_normalization(self): + '''Names should be NFKD normalized''' + self.assertEqual(sanitize_filename('ў'), chr(1091) + chr(774)) + + @patch.object(sys, 'platform', 'win32') + def test_extensions(self): + '''Filename extensions should be preserved when possible.''' + really_long_name = 'X' * 1000 + '.pdf' + self.assertTrue(sanitize_filename(really_long_name).endswith('.pdf')) + self.assertTrue(sanitize_filename('X' * 1000).endswith('X')) + self.assertTrue(sanitize_filename( + 'X' * 100 + '.' + 'X' * 100 + '.pdf').endswith('.pdf')) + self.assertTrue(sanitize_filename( + 'X' * 100 + '.' + 'X' * 400).endswith('X')) + self.assertTrue(sanitize_filename( + 'X' * 100 + '.' + 'X' * 400 + '.pdf').endswith('.pdf')) + + +if __name__ == '__main__': + unittest.main()
View it on GitLab: https://dev.gajim.org/gajim/gajim/-/compare/8af47b1b53332370dba67902d88575c2...