Show senders in MAX group chats
This commit is contained in:
@@ -22,8 +22,8 @@ android {
|
|||||||
applicationId = "xyz.kusoft.qmax"
|
applicationId = "xyz.kusoft.qmax"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 59
|
versionCode = 60
|
||||||
versionName = "1.0.2"
|
versionName = "1.0.3"
|
||||||
|
|
||||||
buildConfigField("String", "QMAX_DEFAULT_SERVER_URL", "\"https://qmax.kusoft.xyz\"")
|
buildConfigField("String", "QMAX_DEFAULT_SERVER_URL", "\"https://qmax.kusoft.xyz\"")
|
||||||
buildConfigField("String", "QMAX_DEFAULT_PAIRING_CODE", "\"qmax-MxRq4h2HQBEIFs6k\"")
|
buildConfigField("String", "QMAX_DEFAULT_PAIRING_CODE", "\"qmax-MxRq4h2HQBEIFs6k\"")
|
||||||
|
|||||||
@@ -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(
|
TelegramChatListContent(
|
||||||
state = state,
|
state = state,
|
||||||
filteredChats = filteredChats,
|
filteredChats = filteredChats,
|
||||||
@@ -4013,6 +4021,7 @@ private fun ChatScreen(vm: QMaxViewModel) {
|
|||||||
is TimelineItem.Day -> DaySeparator(item.label)
|
is TimelineItem.Day -> DaySeparator(item.label)
|
||||||
is TimelineItem.Message -> MessageRow(
|
is TimelineItem.Message -> MessageRow(
|
||||||
message = item.message,
|
message = item.message,
|
||||||
|
showSenderName = chat.kind.equals("Group", ignoreCase = true),
|
||||||
session = session,
|
session = session,
|
||||||
vm = vm,
|
vm = vm,
|
||||||
imagePreviewSources = imagePreviewSources,
|
imagePreviewSources = imagePreviewSources,
|
||||||
@@ -4776,6 +4785,7 @@ private fun LinkifiedMessageText(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun MessageRow(
|
private fun MessageRow(
|
||||||
message: MessageDto,
|
message: MessageDto,
|
||||||
|
showSenderName: Boolean,
|
||||||
session: QMaxSession,
|
session: QMaxSession,
|
||||||
vm: QMaxViewModel,
|
vm: QMaxViewModel,
|
||||||
imagePreviewSources: List<String>,
|
imagePreviewSources: List<String>,
|
||||||
@@ -4850,6 +4860,19 @@ private fun MessageRow(
|
|||||||
Column(
|
Column(
|
||||||
modifier = bubbleModifier
|
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 {
|
message.forwardedFrom?.takeIf { it.isNotBlank() }?.let {
|
||||||
ForwardedFromBlock(
|
ForwardedFromBlock(
|
||||||
name = it,
|
name = it,
|
||||||
|
|||||||
@@ -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
|
||||||
+53
-10
@@ -22,6 +22,7 @@ from aiohttp import web
|
|||||||
from pymax import Client, ExtraConfig, File, Photo, SyncOverrides, Video
|
from pymax import Client, ExtraConfig, File, Photo, SyncOverrides, Video
|
||||||
from pymax.types import ContactInfo
|
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
|
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:
|
def normalize_chat_kind(chat: Any) -> str:
|
||||||
chat_type = value_name(getattr(chat, "type", "")).upper()
|
return normalize_chat_kind_value(getattr(chat, "type", ""))
|
||||||
if "CHANNEL" in chat_type:
|
|
||||||
return "Channel"
|
|
||||||
return "MaxDialog"
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_attachment_kind(att: Any) -> str:
|
def normalize_attachment_kind(att: Any) -> str:
|
||||||
@@ -735,6 +733,7 @@ async def normalize_message(
|
|||||||
message: Any,
|
message: Any,
|
||||||
user_map: dict[str, Any] | None = None,
|
user_map: dict[str, Any] | None = None,
|
||||||
me_id: int | None = None,
|
me_id: int | None = None,
|
||||||
|
phone_contact_names: dict[str, str] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
attaches = list(getattr(message, "attaches", None) or [])
|
attaches = list(getattr(message, "attaches", None) or [])
|
||||||
call_attaches = [att for att in attaches if normalize_attachment_kind(att) == "call"]
|
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)
|
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
|
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
|
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 {
|
return {
|
||||||
"externalId": clean_id(getattr(message, "id", None)),
|
"externalId": clean_id(getattr(message, "id", None)),
|
||||||
"senderExternalId": clean_id(sender) or None,
|
"senderExternalId": clean_id(sender) or None,
|
||||||
@@ -772,6 +776,7 @@ async def normalize_chat_update(
|
|||||||
include_history: bool = False,
|
include_history: bool = False,
|
||||||
user_map: dict[str, Any] | None = None,
|
user_map: dict[str, Any] | None = None,
|
||||||
me_id: int | None = None,
|
me_id: int | None = None,
|
||||||
|
phone_contact_names: dict[str, str] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
last_message = getattr(chat, "last_message", None)
|
last_message = getattr(chat, "last_message", None)
|
||||||
raw_messages: list[Any] = []
|
raw_messages: list[Any] = []
|
||||||
@@ -786,7 +791,12 @@ async def normalize_chat_update(
|
|||||||
me_id = get_me_user_id(client)
|
me_id = get_me_user_id(client)
|
||||||
if user_map is None:
|
if user_map is None:
|
||||||
user_map = await build_user_map(client, [chat], raw_messages)
|
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_preview = None
|
||||||
last_at = getattr(chat, "last_event_time", 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)
|
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:
|
def append_saved_contacts(client: Client, contacts: list[Any]) -> None:
|
||||||
saved = client.contacts
|
saved = client.contacts
|
||||||
saved_ids = {
|
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)
|
return json_response({"success": False, "error": "contacts limit is 5000"}, status=400)
|
||||||
|
|
||||||
contacts_to_import: list[ContactInfo] = []
|
contacts_to_import: list[ContactInfo] = []
|
||||||
requested_phone_keys: set[str] = set()
|
requested_names_by_phone: dict[str, str] = {}
|
||||||
for item in raw_contacts:
|
for item in raw_contacts:
|
||||||
if not isinstance(item, dict):
|
if not isinstance(item, dict):
|
||||||
continue
|
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()
|
first_name = str(item.get("firstName") or item.get("name") or phone).strip()
|
||||||
last_name = str(item.get("lastName") or "").strip() or None
|
last_name = str(item.get("lastName") or "").strip() or None
|
||||||
key = phone_key(phone)
|
key = phone_key(phone)
|
||||||
if phone and key and key not in requested_phone_keys:
|
if phone and key and key not in requested_names_by_phone:
|
||||||
requested_phone_keys.add(key)
|
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))
|
contacts_to_import.append(ContactInfo(phone=phone, first_name=first_name or phone, last_name=last_name))
|
||||||
if not contacts_to_import:
|
if not contacts_to_import:
|
||||||
return json_response({"success": False, "error": "contacts contain no phone numbers"}, status=400)
|
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 (
|
if (
|
||||||
contact_id is not None
|
contact_id is not None
|
||||||
and contact_id > 0
|
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
|
matched_by_id[contact_id] = contact
|
||||||
|
|
||||||
@@ -1106,6 +1140,15 @@ async def contacts_import(request: web.Request) -> web.Response:
|
|||||||
and contact_id > 0
|
and contact_id > 0
|
||||||
}
|
}
|
||||||
save_phone_contact_ids(imported_ids)
|
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)
|
me_id = get_me_user_id(client)
|
||||||
result = [
|
result = [
|
||||||
normalized
|
normalized
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user