diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 4917784..81b4cbf 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -22,8 +22,8 @@ android { applicationId = "xyz.kusoft.qmax" minSdk = 26 targetSdk = 36 - versionCode = 59 - versionName = "1.0.2" + versionCode = 60 + versionName = "1.0.3" buildConfigField("String", "QMAX_DEFAULT_SERVER_URL", "\"https://qmax.kusoft.xyz\"") buildConfigField("String", "QMAX_DEFAULT_PAIRING_CODE", "\"qmax-MxRq4h2HQBEIFs6k\"") diff --git a/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt b/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt index 1ffaccc..728fa63 100644 --- a/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt +++ b/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt @@ -689,6 +689,14 @@ private fun ChatListScreen( } } + LaunchedEffect(state.session?.userName) { + if (state.session != null && + ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) == PackageManager.PERMISSION_GRANTED + ) { + importPhoneContacts() + } + } + TelegramChatListContent( state = state, filteredChats = filteredChats, @@ -4013,6 +4021,7 @@ private fun ChatScreen(vm: QMaxViewModel) { is TimelineItem.Day -> DaySeparator(item.label) is TimelineItem.Message -> MessageRow( message = item.message, + showSenderName = chat.kind.equals("Group", ignoreCase = true), session = session, vm = vm, imagePreviewSources = imagePreviewSources, @@ -4776,6 +4785,7 @@ private fun LinkifiedMessageText( @Composable private fun MessageRow( message: MessageDto, + showSenderName: Boolean, session: QMaxSession, vm: QMaxViewModel, imagePreviewSources: List, @@ -4850,6 +4860,19 @@ private fun MessageRow( Column( modifier = bubbleModifier ) { + if (showSenderName && !outgoing) { + message.senderName?.takeIf { it.isNotBlank() }?.let { senderName -> + Text( + text = senderName, + color = QMaxBlue, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(start = 8.dp, end = 8.dp, top = 6.dp, bottom = 2.dp) + ) + } + } message.forwardedFrom?.takeIf { it.isNotBlank() }?.let { ForwardedFromBlock( name = it, diff --git a/pymax-worker/src/chat_identity.py b/pymax-worker/src/chat_identity.py new file mode 100644 index 0000000..55ed1f6 --- /dev/null +++ b/pymax-worker/src/chat_identity.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from typing import Any, Mapping + + +def normalize_chat_kind_value(value: Any) -> str: + raw = getattr(value, "value", value) + text = str(raw or "").strip().upper() + if text.startswith("CHATTYPE."): + text = text.removeprefix("CHATTYPE.") + if text == "CHANNEL": + return "Channel" + if text in {"CHAT", "GROUP"}: + return "Group" + return "MaxDialog" + + +def resolve_sender_name( + sender_id: int | None, + *, + is_outgoing: bool, + phone_contact_names: Mapping[str, str] | None, + profile_name: str | None, +) -> str | None: + if is_outgoing: + return "You" + if sender_id is not None: + phone_name = (phone_contact_names or {}).get(str(sender_id)) + if phone_name and phone_name.strip(): + return phone_name.strip() + return profile_name.strip() if profile_name and profile_name.strip() else None diff --git a/pymax-worker/src/server.py b/pymax-worker/src/server.py index e6c0c2b..51fde2d 100644 --- a/pymax-worker/src/server.py +++ b/pymax-worker/src/server.py @@ -22,6 +22,7 @@ from aiohttp import web from pymax import Client, ExtraConfig, File, Photo, SyncOverrides, Video from pymax.types import ContactInfo +from .chat_identity import normalize_chat_kind_value, resolve_sender_name from .message_labels import INCOMING_CALL_TEXT, is_call_media_label @@ -572,10 +573,7 @@ def attachment_preview(attaches: list[Any]) -> str | None: def normalize_chat_kind(chat: Any) -> str: - chat_type = value_name(getattr(chat, "type", "")).upper() - if "CHANNEL" in chat_type: - return "Channel" - return "MaxDialog" + return normalize_chat_kind_value(getattr(chat, "type", "")) def normalize_attachment_kind(att: Any) -> str: @@ -735,6 +733,7 @@ async def normalize_message( message: Any, user_map: dict[str, Any] | None = None, me_id: int | None = None, + phone_contact_names: dict[str, str] | None = None, ) -> dict[str, Any]: attaches = list(getattr(message, "attaches", None) or []) call_attaches = [att for att in attaches if normalize_attachment_kind(att) == "call"] @@ -753,7 +752,12 @@ async def normalize_message( text = INCOMING_CALL_TEXT if call_attaches or is_call_text else raw_text or attachment_preview(attaches) status = value_name(getattr(message, "status", None)).lower() or None user = (user_map or {}).get(str(sender_id)) if sender_id is not None else None - sender_name = "You" if is_outgoing else user_display_name(user) + sender_name = resolve_sender_name( + sender_id, + is_outgoing=is_outgoing, + phone_contact_names=phone_contact_names, + profile_name=user_display_name(user), + ) return { "externalId": clean_id(getattr(message, "id", None)), "senderExternalId": clean_id(sender) or None, @@ -772,6 +776,7 @@ async def normalize_chat_update( include_history: bool = False, user_map: dict[str, Any] | None = None, me_id: int | None = None, + phone_contact_names: dict[str, str] | None = None, ) -> dict[str, Any]: last_message = getattr(chat, "last_message", None) raw_messages: list[Any] = [] @@ -786,7 +791,12 @@ async def normalize_chat_update( me_id = get_me_user_id(client) if user_map is None: user_map = await build_user_map(client, [chat], raw_messages) - messages = [await normalize_message(client, chat, m, user_map, me_id) for m in raw_messages] + if phone_contact_names is None: + phone_contact_names = load_phone_contact_names() + messages = [ + await normalize_message(client, chat, m, user_map, me_id, phone_contact_names) + for m in raw_messages + ] last_preview = None last_at = getattr(chat, "last_event_time", None) @@ -1038,6 +1048,30 @@ def save_phone_contact_ids(contact_ids: set[int]) -> None: part_path.replace(path) +def load_phone_contact_names() -> dict[str, str]: + path = runtime.session_dir / "phone-contact-names.json" + try: + values = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError, TypeError): + return {} + if not isinstance(values, dict): + return {} + return { + str(contact_id): str(name).strip() + for contact_id, name in values.items() + if coerce_int(contact_id) is not None and str(name).strip() + } + + +def save_phone_contact_names(contact_names: dict[int, str]) -> None: + runtime.session_dir.mkdir(parents=True, exist_ok=True) + path = runtime.session_dir / "phone-contact-names.json" + part_path = path.with_suffix(".json.part") + values = {str(contact_id): name.strip() for contact_id, name in contact_names.items() if name.strip()} + part_path.write_text(json.dumps(values, ensure_ascii=False, sort_keys=True), encoding="utf-8") + part_path.replace(path) + + def append_saved_contacts(client: Client, contacts: list[Any]) -> None: saved = client.contacts saved_ids = { @@ -1062,7 +1096,7 @@ async def contacts_import(request: web.Request) -> web.Response: return json_response({"success": False, "error": "contacts limit is 5000"}, status=400) contacts_to_import: list[ContactInfo] = [] - requested_phone_keys: set[str] = set() + requested_names_by_phone: dict[str, str] = {} for item in raw_contacts: if not isinstance(item, dict): continue @@ -1070,8 +1104,8 @@ async def contacts_import(request: web.Request) -> web.Response: first_name = str(item.get("firstName") or item.get("name") or phone).strip() last_name = str(item.get("lastName") or "").strip() or None key = phone_key(phone) - if phone and key and key not in requested_phone_keys: - requested_phone_keys.add(key) + if phone and key and key not in requested_names_by_phone: + requested_names_by_phone[key] = " ".join(part for part in (first_name, last_name) if part).strip() contacts_to_import.append(ContactInfo(phone=phone, first_name=first_name or phone, last_name=last_name)) if not contacts_to_import: return json_response({"success": False, "error": "contacts contain no phone numbers"}, status=400) @@ -1094,7 +1128,7 @@ async def contacts_import(request: web.Request) -> web.Response: if ( contact_id is not None and contact_id > 0 - and phone_key(contact_data.get("phone") or getattr(contact, "phone", None)) in requested_phone_keys + and phone_key(contact_data.get("phone") or getattr(contact, "phone", None)) in requested_names_by_phone ): matched_by_id[contact_id] = contact @@ -1106,6 +1140,15 @@ async def contacts_import(request: web.Request) -> web.Response: and contact_id > 0 } save_phone_contact_ids(imported_ids) + phone_names_by_id = { + contact_id: requested_names_by_phone[contact_phone_key] + for contact in matched + if (contact_data := dump_model(contact)) + if (contact_id := coerce_int(contact_data.get("id") or getattr(contact, "id", None))) is not None + if (contact_phone_key := phone_key(contact_data.get("phone") or getattr(contact, "phone", None))) + in requested_names_by_phone + } + save_phone_contact_names(phone_names_by_id) me_id = get_me_user_id(client) result = [ normalized diff --git a/pymax-worker/tests/test_chat_identity.py b/pymax-worker/tests/test_chat_identity.py new file mode 100644 index 0000000..8b0ad7d --- /dev/null +++ b/pymax-worker/tests/test_chat_identity.py @@ -0,0 +1,62 @@ +import unittest +from enum import Enum + +from src.chat_identity import normalize_chat_kind_value, resolve_sender_name + + +class SampleChatType(str, Enum): + DIALOG = "DIALOG" + CHAT = "CHAT" + CHANNEL = "CHANNEL" + + +class ChatIdentityTests(unittest.TestCase): + def test_chat_kind_is_normalized(self) -> None: + cases = { + SampleChatType.DIALOG: "MaxDialog", + SampleChatType.CHAT: "Group", + SampleChatType.CHANNEL: "Channel", + "ChatType.CHAT": "Group", + "GROUP": "Group", + None: "MaxDialog", + } + for value, expected in cases.items(): + with self.subTest(value=value): + self.assertEqual(expected, normalize_chat_kind_value(value)) + + def test_phone_book_name_has_priority(self) -> None: + self.assertEqual( + "Мама", + resolve_sender_name( + 42, + is_outgoing=False, + phone_contact_names={"42": " Мама "}, + profile_name="MAX Profile", + ), + ) + + def test_profile_name_is_used_when_phone_name_is_missing(self) -> None: + self.assertEqual( + "MAX Profile", + resolve_sender_name( + 42, + is_outgoing=False, + phone_contact_names={}, + profile_name="MAX Profile", + ), + ) + + def test_outgoing_message_uses_you(self) -> None: + self.assertEqual( + "You", + resolve_sender_name( + 42, + is_outgoing=True, + phone_contact_names={"42": "Мама"}, + profile_name="MAX Profile", + ), + ) + + +if __name__ == "__main__": + unittest.main()