Working avatars and icons

This commit is contained in:
Andrew Ferrazzutti 2021-03-26 02:27:21 -04:00
parent b007751610
commit 884d0d32fe
12 changed files with 204 additions and 95 deletions

View File

@ -17,7 +17,7 @@ from typing import Optional, ClassVar, List, TYPE_CHECKING
from attr import dataclass from attr import dataclass
from mautrix.types import RoomID from mautrix.types import RoomID, ContentURI
from mautrix.util.async_db import Database from mautrix.util.async_db import Database
fake_db = Database("") if TYPE_CHECKING else None fake_db = Database("") if TYPE_CHECKING else None
@ -28,27 +28,31 @@ class Portal:
db: ClassVar[Database] = fake_db db: ClassVar[Database] = fake_db
chat_id: str chat_id: str
other_user: str other_user: str # TODO Remove, as it's redundant: other_user == chat_id for direct chats
mxid: Optional[RoomID] mxid: Optional[RoomID]
name: Optional[str] name: Optional[str]
icon_url: Optional[str] icon_path: Optional[str]
icon_mxc: Optional[ContentURI]
encrypted: bool encrypted: bool
async def insert(self) -> None: async def insert(self) -> None:
q = ("INSERT INTO portal (chat_id, other_user, mxid, name, icon_url, encrypted) " q = ("INSERT INTO portal (chat_id, other_user, mxid, name, icon_path, icon_mxc, encrypted) "
"VALUES ($1, $2, $3, $4, $5, $6)") "VALUES ($1, $2, $3, $4, $5, $6, $7)")
await self.db.execute(q, self.chat_id, self.other_user, self.mxid, self.name, await self.db.execute(q, self.chat_id, self.other_user, self.mxid, self.name,
self.icon_url, self.encrypted) self.icon_path, self.icon_mxc,
self.encrypted)
async def update(self) -> None: async def update(self) -> None:
q = ("UPDATE portal SET other_user=$2, mxid=$3, name=$4, icon_url=$5, encrypted=$6 " q = ("UPDATE portal SET other_user=$2, mxid=$3, name=$4, "
" icon_path=$5, icon_mxc=$6, encrypted=$7 "
"WHERE chat_id=$1") "WHERE chat_id=$1")
await self.db.execute(q, self.chat_id, self.other_user, await self.db.execute(q, self.chat_id, self.other_user, self.mxid, self.name,
self.mxid, self.name, self.icon_url, self.encrypted) self.icon_path, self.icon_mxc,
self.encrypted)
@classmethod @classmethod
async def get_by_mxid(cls, mxid: RoomID) -> Optional['Portal']: async def get_by_mxid(cls, mxid: RoomID) -> Optional['Portal']:
q = ("SELECT chat_id, other_user, mxid, name, icon_url, encrypted " q = ("SELECT chat_id, other_user, mxid, name, icon_path, icon_mxc, encrypted "
"FROM portal WHERE mxid=$1") "FROM portal WHERE mxid=$1")
row = await cls.db.fetchrow(q, mxid) row = await cls.db.fetchrow(q, mxid)
if not row: if not row:
@ -57,7 +61,7 @@ class Portal:
@classmethod @classmethod
async def get_by_chat_id(cls, chat_id: int) -> Optional['Portal']: async def get_by_chat_id(cls, chat_id: int) -> Optional['Portal']:
q = ("SELECT chat_id, other_user, mxid, name, icon_url, encrypted " q = ("SELECT chat_id, other_user, mxid, name, icon_path, icon_mxc, encrypted "
"FROM portal WHERE chat_id=$1") "FROM portal WHERE chat_id=$1")
row = await cls.db.fetchrow(q, chat_id) row = await cls.db.fetchrow(q, chat_id)
if not row: if not row:
@ -66,12 +70,14 @@ class Portal:
@classmethod @classmethod
async def find_private_chats(cls) -> List['Portal']: async def find_private_chats(cls) -> List['Portal']:
rows = await cls.db.fetch("SELECT chat_id, other_user, mxid, name, icon_url, encrypted " rows = await cls.db.fetch("SELECT chat_id, other_user, mxid, name, "
" icon_path, icon_mxc, encrypted "
"FROM portal WHERE other_user IS NOT NULL") "FROM portal WHERE other_user IS NOT NULL")
return [cls(**row) for row in rows] return [cls(**row) for row in rows]
@classmethod @classmethod
async def all_with_room(cls) -> List['Portal']: async def all_with_room(cls) -> List['Portal']:
rows = await cls.db.fetch("SELECT chat_id, other_user, mxid, name, icon_url, encrypted " rows = await cls.db.fetch("SELECT chat_id, other_user, mxid, name, "
" icon_path, icon_mxc, encrypted "
"FROM portal WHERE mxid IS NOT NULL") "FROM portal WHERE mxid IS NOT NULL")
return [cls(**row) for row in rows] return [cls(**row) for row in rows]

View File

@ -17,6 +17,7 @@ from typing import Optional, ClassVar, TYPE_CHECKING
from attr import dataclass from attr import dataclass
from mautrix.types import ContentURI
from mautrix.util.async_db import Database from mautrix.util.async_db import Database
fake_db = Database("") if TYPE_CHECKING else None fake_db = Database("") if TYPE_CHECKING else None
@ -28,21 +29,35 @@ class Puppet:
mid: str mid: str
name: Optional[str] name: Optional[str]
avatar_url: Optional[str] avatar_path: Optional[str]
avatar_mxc: Optional[ContentURI]
name_set: bool
avatar_set: bool
is_registered: bool is_registered: bool
async def insert(self) -> None: async def insert(self) -> None:
q = "INSERT INTO puppet (mid, name, avatar_url, is_registered) VALUES ($1, $2, $3, $4)" q = ("INSERT INTO puppet (mid, name, "
await self.db.execute(q, self.mid, self.name, self.avatar_url, self.is_registered) " avatar_path, avatar_mxc, name_set, avatar_set, "
" is_registered) "
"VALUES ($1, $2, $3, $4, $5, $6, $7)")
await self.db.execute(q, self.mid, self.name,
self.avatar_path, self.avatar_mxc, self.name_set, self.avatar_set,
self.is_registered)
async def update(self) -> None: async def update(self) -> None:
q = "UPDATE puppet SET name=$2, avatar_url=$3, is_registered=$4 WHERE mid=$1" q = ("UPDATE puppet SET name=$2, "
await self.db.execute(q, self.mid, self.name, self.avatar_url, self.is_registered) " avatar_path=$3, avatar_mxc=$4, name_set=$5, avatar_set=$6, "
" is_registered=$7 "
"WHERE mid=$1")
await self.db.execute(q, self.mid, self.name,
self.avatar_path, self.avatar_mxc, self.name_set, self.avatar_set,
self.is_registered)
@classmethod @classmethod
async def get_by_mid(cls, mid: str) -> Optional['Puppet']: async def get_by_mid(cls, mid: str) -> Optional['Puppet']:
row = await cls.db.fetchrow("SELECT mid, name, avatar_url, is_registered FROM puppet WHERE mid=$1", q = ("SELECT mid, name, avatar_path, avatar_mxc, name_set, avatar_set, is_registered "
mid) "FROM puppet WHERE mid=$1")
row = await cls.db.fetchrow(q, mid)
if not row: if not row:
return None return None
return cls(**row) return cls(**row)

View File

@ -50,9 +50,13 @@ async def upgrade_v1(conn: Connection) -> None:
@upgrade_table.register(description="Avatars and icons") @upgrade_table.register(description="Avatars and icons")
async def upgrade_avatars(conn: Connection) -> None: async def upgrade_avatars(conn: Connection) -> None:
for (table, column) in [('puppet', 'avatar_url'), ('portal', 'icon_url')]: await conn.execute("""ALTER TABLE puppet
column_exists = await conn.fetchval( ADD COLUMN IF NOT EXISTS avatar_path TEXT,
"SELECT EXISTS(SELECT FROM information_schema.columns " ADD COLUMN IF NOT EXISTS avatar_mxc TEXT,
f"WHERE table_name='{table}' AND column_name='{column}')") ADD COLUMN IF NOT EXISTS name_set BOOLEAN,
if not column_exists: ADD COLUMN IF NOT EXISTS avatar_set BOOLEAN
await conn.execute(f'ALTER TABLE "{table}" ADD COLUMN {column} TEXT') """)
await conn.execute("""ALTER TABLE portal
ADD COLUMN IF NOT EXISTS icon_path TEXT,
ADD COLUMN IF NOT EXISTS icon_mxc TEXT
""")

View File

@ -30,7 +30,7 @@ from mautrix.util.network_retry import call_with_net_retry
from .db import Portal as DBPortal, Message as DBMessage from .db import Portal as DBPortal, Message as DBMessage
from .config import Config from .config import Config
from .rpc import ChatInfo, Participant, Message from .rpc import ChatInfo, Participant, Message, Client, PathImage
from . import user as u, puppet as p, matrix as m from . import user as u, puppet as p, matrix as m
if TYPE_CHECKING: if TYPE_CHECKING:
@ -62,9 +62,10 @@ class Portal(DBPortal, BasePortal):
_last_participant_update: Set[str] _last_participant_update: Set[str]
def __init__(self, chat_id: int, other_user: Optional[str] = None, def __init__(self, chat_id: int, other_user: Optional[str] = None,
mxid: Optional[RoomID] = None, name: Optional[str] = None, icon_url: Optional[str] = None, mxid: Optional[RoomID] = None, name: Optional[str] = None,
icon_path: Optional[str] = None, icon_mxc: Optional[ContentURI] = None,
encrypted: bool = False) -> None: encrypted: bool = False) -> None:
super().__init__(chat_id, other_user, mxid, name, icon_url, encrypted) super().__init__(chat_id, other_user, mxid, name, icon_path, icon_mxc, encrypted)
self._create_room_lock = asyncio.Lock() self._create_room_lock = asyncio.Lock()
self.log = self.log.getChild(str(chat_id)) self.log = self.log.getChild(str(chat_id))
@ -229,18 +230,25 @@ class Portal(DBPortal, BasePortal):
return ReuploadedMediaInfo(mxc, decryption_info, mime_type, file_name, len(data)) return ReuploadedMediaInfo(mxc, decryption_info, mime_type, file_name, len(data))
async def update_info(self, conv: ChatInfo) -> None: async def update_info(self, conv: ChatInfo, client: Optional[Client]) -> None:
if self.is_direct: if self.is_direct:
self.other_user = conv.participants[0].id self.other_user = conv.participants[0].id
if self._main_intent is self.az.intent: if self._main_intent is self.az.intent:
self._main_intent = (await p.Puppet.get_by_mid(self.other_user)).intent self._main_intent = (await p.Puppet.get_by_mid(self.other_user)).intent
for participant in conv.participants: for participant in conv.participants:
puppet = await p.Puppet.get_by_mid(participant.id) puppet = await p.Puppet.get_by_mid(participant.id)
await puppet.update_info(participant) await puppet.update_info(participant, client)
# TODO Consider setting no room name for non-group chats. # TODO Consider setting no room name for non-group chats.
# But then the LINE bot itself may appear in the title... # But then the LINE bot itself may appear in the title...
changed = await self._update_name(f"{conv.name} (LINE)") changed = await self._update_name(f"{conv.name} (LINE)")
changed = await self._update_icon(conv.iconURL) or changed if client:
if not self.is_direct:
changed = await self._update_icon(conv.icon, client) or changed
elif puppet and puppet.avatar_mxc != self.icon_mxc:
changed = True
self.icon_mxc = puppet.avatar_mxc
if self.mxid:
await self.main_intent.set_room_avatar(self.mxid, self.icon_mxc)
if changed: if changed:
await self.update_bridge_info() await self.update_bridge_info()
await self.update() await self.update()
@ -256,12 +264,17 @@ class Portal(DBPortal, BasePortal):
return True return True
return False return False
async def _update_icon(self, icon_url: Optional[str]) -> bool: async def _update_icon(self, icon: Optional[PathImage], client: Client) -> bool:
if self.icon_url != icon_url: icon_path = icon.path if icon else None
self.icon_url = icon_url if icon_path != self.icon_path:
if icon_url: self.icon_path = icon_path
# TODO set icon from bytes if icon and icon.url:
pass resp = await client.read_image(icon.url)
self.icon_mxc = await self.main_intent.upload_media(resp.data, mime_type=resp.mime)
else:
self.icon_mxc = ContentURI("")
if self.mxid:
await self.main_intent.set_room_avatar(self.mxid, self.icon_mxc)
return True return True
return False return False
@ -319,7 +332,7 @@ class Portal(DBPortal, BasePortal):
for evt in messages: for evt in messages:
puppet = await p.Puppet.get_by_mid(evt.sender.id) if not self.is_direct else None puppet = await p.Puppet.get_by_mid(evt.sender.id) if not self.is_direct else None
if puppet and evt.sender.id not in members_known: if puppet and evt.sender.id not in members_known:
await puppet.update_info(evt.sender) await puppet.update_info(evt.sender, source.client)
members_known.add(evt.sender.id) members_known.add(evt.sender.id)
await self.handle_remote_message(source, puppet, evt) await self.handle_remote_message(source, puppet, evt)
self.log.info("Backfilled %d messages through %s", len(messages), source.mxid) self.log.info("Backfilled %d messages through %s", len(messages), source.mxid)
@ -377,7 +390,7 @@ class Portal(DBPortal, BasePortal):
if puppet: if puppet:
await puppet.az.intent.ensure_joined(self.mxid) await puppet.az.intent.ensure_joined(self.mxid)
await self.update_info(info) await self.update_info(info, source.client)
await self.backfill(source) await self.backfill(source)
await self._update_participants(info.participants) await self._update_participants(info.participants)
@ -385,7 +398,7 @@ class Portal(DBPortal, BasePortal):
if self.mxid: if self.mxid:
await self._update_matrix_room(source, info) await self._update_matrix_room(source, info)
return self.mxid return self.mxid
await self.update_info(info) await self.update_info(info, source.client)
self.log.debug("Creating Matrix room") self.log.debug("Creating Matrix room")
name: Optional[str] = None name: Optional[str] = None
initial_state = [{ initial_state = [{
@ -483,7 +496,10 @@ class Portal(DBPortal, BasePortal):
self.by_mxid.pop(self.mxid, None) self.by_mxid.pop(self.mxid, None)
self.mxid = None self.mxid = None
self.name = None self.name = None
self.icon_url = None self.icon_path = None
self.icon_mxc = None
self.name_set = False
self.icon_set = False
self.encrypted = False self.encrypted = False
await self.update() await self.update()

View File

@ -16,12 +16,12 @@
from typing import Optional, Dict, TYPE_CHECKING, cast from typing import Optional, Dict, TYPE_CHECKING, cast
from mautrix.bridge import BasePuppet from mautrix.bridge import BasePuppet
from mautrix.types import UserID from mautrix.types import UserID, ContentURI
from mautrix.util.simple_template import SimpleTemplate from mautrix.util.simple_template import SimpleTemplate
from .db import Puppet as DBPuppet from .db import Puppet as DBPuppet
from .config import Config from .config import Config
from .rpc import Participant from .rpc import Participant, Client, PathImage
from . import user as u from . import user as u
if TYPE_CHECKING: if TYPE_CHECKING:
@ -38,9 +38,11 @@ class Puppet(DBPuppet, BasePuppet):
default_mxid: UserID default_mxid: UserID
def __init__(self, mid: str, name: Optional[str] = None, avatar_url: Optional[str] = None, def __init__(self, mid: str, name: Optional[str] = None,
avatar_path: Optional[str] = None, avatar_mxc: Optional[ContentURI] = None,
name_set: bool = False, avatar_set: bool = False,
is_registered: bool = False) -> None: is_registered: bool = False) -> None:
super().__init__(mid=mid, name=name, avatar_url=avatar_url, is_registered=is_registered) super().__init__(mid, name, avatar_path, avatar_mxc, name_set, avatar_set, is_registered)
self.log = self.log.getChild(mid) self.log = self.log.getChild(mid)
self.default_mxid = self.get_mxid_from_id(mid) self.default_mxid = self.get_mxid_from_id(mid)
@ -61,27 +63,46 @@ class Puppet(DBPuppet, BasePuppet):
cls.login_shared_secret_map[cls.hs_domain] = secret.encode("utf-8") cls.login_shared_secret_map[cls.hs_domain] = secret.encode("utf-8")
cls.login_device_name = "LINE Bridge" cls.login_device_name = "LINE Bridge"
async def update_info(self, info: Participant) -> None: async def update_info(self, info: Participant, client: Optional[Client]) -> None:
update = False update = False
update = await self._update_name(info.name) or update update = await self._update_name(info.name) or update
update = await self._update_avatar(info.avatarURL) or update if client:
update = await self._update_avatar(info.avatar, client) or update
if update: if update:
await self.update() await self.update()
async def _update_name(self, name: str) -> bool: async def _update_name(self, name: str) -> bool:
name = self.config["bridge.displayname_template"].format(displayname=name) name = self.config["bridge.displayname_template"].format(displayname=name)
if name != self.name: if name != self.name or not self.name_set:
self.name = name self.name = name
await self.intent.set_displayname(self.name) try:
await self.intent.set_displayname(self.name)
self.name_set = True
except Exception:
self.log.exception("Failed to set displayname")
self.name_set = False
return True return True
return False return False
async def _update_avatar(self, avatar_url: Optional[str]) -> bool: async def _update_avatar(self, avatar: Optional[PathImage], client: Client) -> bool:
if avatar_url != self.avatar_url: if avatar and avatar.url and not avatar.path:
self.avatar_url = avatar_url # Avatar exists, but in a form that cannot be uniquely identified.
if avatar_url: # Skip it for now.
# TODO set the avatar from bytes return False
pass avatar_path = avatar.path if avatar else None
if avatar_path != self.avatar_path or not self.avatar_set:
self.avatar_path = avatar_path
if avatar and avatar.url:
resp = await client.read_image(avatar.url)
self.avatar_mxc = await self.intent.upload_media(resp.data, mime_type=resp.mime)
else:
self.avatar_mxc = ContentURI("")
try:
await self.intent.set_avatar_url(self.avatar_mxc)
self.avatar_set = True
except Exception:
self.log.exception("Failed to set user avatar")
self.avatar_set = False
return True return True
return False return False

View File

@ -1,2 +1,2 @@
from .client import Client from .client import Client
from .types import RPCError, ChatListInfo, ChatInfo, Participant, Message, StartStatus from .types import RPCError, PathImage, ChatListInfo, ChatInfo, Participant, Message, StartStatus

View File

@ -15,10 +15,11 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import AsyncGenerator, TypedDict, List, Tuple, Dict, Callable, Awaitable, Any from typing import AsyncGenerator, TypedDict, List, Tuple, Dict, Callable, Awaitable, Any
from collections import deque from collections import deque
from base64 import b64decode
import asyncio import asyncio
from .rpc import RPCClient from .rpc import RPCClient
from .types import ChatListInfo, ChatInfo, Message, StartStatus from .types import ChatListInfo, ChatInfo, Message, ImageData, StartStatus
class LoginCommand(TypedDict): class LoginCommand(TypedDict):
@ -45,6 +46,25 @@ class Client(RPCClient):
resp = await self.request("get_messages", chat_id=chat_id) resp = await self.request("get_messages", chat_id=chat_id)
return [Message.deserialize(data) for data in resp] return [Message.deserialize(data) for data in resp]
async def read_image(self, image_url: str) -> ImageData:
resp = await self.request("read_image", image_url=image_url)
if not resp.startswith("data:"):
raise TypeError("Image data is not in the form of a Data URL")
typestart = 5
typeend = resp.find(",", typestart)
data = bytes(resp[typeend+1:], "utf-8")
paramstart = resp.rfind(";", typestart, typeend)
if paramstart == -1:
mime = resp[typestart:typeend]
else:
mime = resp[typestart:paramstart]
if resp[paramstart+1:typeend] == "base64":
data = b64decode(data)
return ImageData(mime=mime, data=data)
async def is_connected(self) -> bool: async def is_connected(self) -> bool:
resp = await self.request("is_connected") resp = await self.request("is_connected")
return resp["is_connected"] return resp["is_connected"]

View File

@ -161,6 +161,6 @@ class RPCClient:
await self._writer.drain() await self._writer.drain()
return future return future
async def request(self, command: str, **data: Any) -> Dict[str, Any]: async def request(self, command: str, **data: Any) -> Any:
future = await self._raw_request(command, **data) future = await self._raw_request(command, **data)
return await future return await future

View File

@ -24,11 +24,17 @@ class RPCError(Exception):
pass pass
@dataclass
class PathImage(SerializableAttrs['PathImage']):
path: str
url: str
@dataclass @dataclass
class ChatListInfo(SerializableAttrs['ChatListInfo']): class ChatListInfo(SerializableAttrs['ChatListInfo']):
id: int id: int
name: str name: str
iconURL: Optional[str] icon: Optional[PathImage]
lastMsg: str lastMsg: str
lastMsgDate: str lastMsgDate: str
@ -36,7 +42,7 @@ class ChatListInfo(SerializableAttrs['ChatListInfo']):
@dataclass @dataclass
class Participant(SerializableAttrs['Participant']): class Participant(SerializableAttrs['Participant']):
id: str id: str
avatarURL: Optional[str] avatar: Optional[PathImage]
name: str name: str
@ -56,6 +62,12 @@ class Message(SerializableAttrs['Message']):
image: Optional[str] = None image: Optional[str] = None
@dataclass
class ImageData:
mime: str
data: bytes
@dataclass @dataclass
class StartStatus(SerializableAttrs['StartStatus']): class StartStatus(SerializableAttrs['StartStatus']):
started: bool started: bool

View File

@ -232,6 +232,7 @@ export default class Client {
get_chats: () => this.puppet.getRecentChats(), get_chats: () => this.puppet.getRecentChats(),
get_chat: req => this.puppet.getChatInfo(req.chat_id), get_chat: req => this.puppet.getChatInfo(req.chat_id),
get_messages: req => this.puppet.getMessages(req.chat_id), get_messages: req => this.puppet.getMessages(req.chat_id),
read_image: req => this.puppet.readImage(req.image_url),
is_connected: async () => ({ is_connected: !await this.puppet.isDisconnected() }), is_connected: async () => ({ is_connected: !await this.puppet.isDisconnected() }),
}[req.command] || this.handleUnknownCommand }[req.command] || this.handleUnknownCommand
} }

View File

@ -182,7 +182,7 @@ class MautrixController {
const participantsList = document.querySelector(participantsListSelector) const participantsList = document.querySelector(participantsListSelector)
sender.id = participantsList.querySelector(`img[alt='${senderName}'`).parentElement.parentElement.getAttribute("data-mid") sender.id = participantsList.querySelector(`img[alt='${senderName}'`).parentElement.parentElement.getAttribute("data-mid")
} }
sender.avatarURL = this.getParticipantListItemAvatarURL(element) sender.avatar = this.getParticipantListItemAvatar(element)
} else { } else {
// TODO Get own ID and store it somewhere appropriate. // TODO Get own ID and store it somewhere appropriate.
// Unable to get own ID from a room chat... // Unable to get own ID from a room chat...
@ -196,7 +196,7 @@ class MautrixController {
await window.__mautrixShowParticipantsList() await window.__mautrixShowParticipantsList()
const participantsList = document.querySelector(participantsListSelector) const participantsList = document.querySelector(participantsListSelector)
sender.name = this.getParticipantListItemName(participantsList.children[0]) sender.name = this.getParticipantListItemName(participantsList.children[0])
sender.avatarURL = this.getParticipantListItemAvatarURL(participantsList.children[0]) sender.avatar = this.getParticipantListItemAvatar(participantsList.children[0])
sender.id = this.ownID sender.id = this.ownID
} }
@ -299,25 +299,41 @@ class MautrixController {
return messages return messages
} }
/**
* @typedef PathImage
* @type object
* @property {string} path - The virtual path of the image (behaves like an ID)
* @property {string} src - The URL of the image
*/
_getPathImage(img) {
if (img && img.src.startsWith("blob:")) {
// NOTE Having a blob but no path means the image exists,
// but in a form that cannot be uniquely identified.
// If instead there is no blob, the image is blank.
return {
path: img.getAttribute("data-picture-path"),
url: img.src,
}
} else {
return null
}
}
/** /**
* @typedef Participant * @typedef Participant
* @type object * @type object
* @property {string} id - The member ID for the participant * @property {string} id - The member ID for the participant
* @property {string} avatarURL - The URL of the participant's avatar * @property {PathImage} avatar - The path and blob URL of the participant's avatar
* @property {string} name - The contact list name of the participant * @property {string} name - The contact list name of the participant
*/ */
getParticipantListItemName(element) { getParticipantListItemName(element) {
return element.querySelector(".mdRGT13Ttl").innerText return element.querySelector(".mdRGT13Ttl").innerText
} }
getParticipantListItemAvatarURL(element) { getParticipantListItemAvatar(element) {
const img = element.querySelector(".mdRGT13Img img[src]") return this._getPathImage(element.querySelector(".mdRGT13Img img[src]"))
if (img && img.getAttribute("data-picture-path") != "" && img.src.startsWith("blob:")) {
return img.src
} else {
return ""
}
} }
getParticipantListItemId(element) { getParticipantListItemId(element) {
@ -340,7 +356,7 @@ class MautrixController {
// One idea is to add real ID as suffix if we're in a group, and // One idea is to add real ID as suffix if we're in a group, and
// put in the puppet DB table somehow. // put in the puppet DB table somehow.
id: this.ownID, id: this.ownID,
avatarURL: this.getParticipantListItemAvatarURL(element.children[0]), avatar: this.getParticipantListItemAvatar(element.children[0]),
name: this.getParticipantListItemName(element.children[0]), name: this.getParticipantListItemName(element.children[0]),
} }
@ -349,7 +365,7 @@ class MautrixController {
const id = this.getParticipantListItemId(child) || this.getUserIdFromFriendsList(name) const id = this.getParticipantListItemId(child) || this.getUserIdFromFriendsList(name)
return { return {
id: id, // NOTE Don't want non-own user's ID to ever be null. id: id, // NOTE Don't want non-own user's ID to ever be null.
avatarURL: this.getParticipantListItemAvatarURL(child), avatar: this.getParticipantListItemAvatar(child),
name: name, name: name,
} }
})) }))
@ -358,9 +374,9 @@ class MautrixController {
/** /**
* @typedef ChatListInfo * @typedef ChatListInfo
* @type object * @type object
* @property {number} id - The ID of the chat. * @property {number} id - The ID of the chat.
* @property {string} name - The name of the chat. * @property {string} name - The name of the chat.
* @property {string} iconURL - The URL of the chat icon. * @property {PathImage} icon - The path and blob URL of the chat icon.
* @property {string} lastMsg - The most recent message in the chat. * @property {string} lastMsg - The most recent message in the chat.
* May be prefixed by sender name. * May be prefixed by sender name.
* @property {string} lastMsgDate - An imprecise date for the most recent message * @property {string} lastMsgDate - An imprecise date for the most recent message
@ -375,13 +391,8 @@ class MautrixController {
return element.querySelector(".mdCMN04Ttl").innerText return element.querySelector(".mdCMN04Ttl").innerText
} }
getChatListItemIconURL(element) { getChatListItemIcon(element) {
const img = element.querySelector(".mdCMN04Img > :not(.mdCMN04ImgInner) > img[src]") return this._getPathImage(element.querySelector(".mdCMN04Img > :not(.mdCMN04ImgInner) > img[src]"))
if (img && img.getAttribute("data-picture-path") != "" && img.src.startsWith("blob:")) {
return img.src
} else {
return ""
}
} }
getChatListItemLastMsg(element) { getChatListItemLastMsg(element) {
@ -403,7 +414,7 @@ class MautrixController {
return !element.classList.contains("chatList") ? null : { return !element.classList.contains("chatList") ? null : {
id: knownId || this.getChatListItemId(element), id: knownId || this.getChatListItemId(element),
name: this.getChatListItemName(element), name: this.getChatListItemName(element),
iconURL: this.getChatListItemIconURL(element), icon: this.getChatListItemIcon(element),
lastMsg: this.getChatListItemLastMsg(element), lastMsg: this.getChatListItemLastMsg(element),
lastMsgDate: this.getChatListItemLastMsgDate(element), lastMsgDate: this.getChatListItemLastMsgDate(element),
} }
@ -434,17 +445,13 @@ class MautrixController {
} }
/** /**
* TODO * Download an image at a given URL and return it as a data URL.
* Download an image and return it as a data URL.
* Used for downloading the blob: URLs in image messages.
* *
* @param {number} id - The ID of the message whose image to download. * @param {string} url - The URL of the image to download.
* @return {Promise<string>} - The data URL (containing the mime type and base64 data) * @return {Promise<string>} - The data URL (containing the mime type and base64 data)
*/ */
async readImage(id) { async readImage(url) {
const imageElement = document.querySelector( const resp = await fetch(url)
`mws-message-wrapper[msg-id="${id}"] mws-image-message-part .image-msg`)
const resp = await fetch(imageElement.getAttribute("src"))
const reader = new FileReader() const reader = new FileReader()
const promise = new Promise((resolve, reject) => { const promise = new Promise((resolve, reject) => {
reader.onload = () => resolve(reader.result) reader.onload = () => resolve(reader.result)

View File

@ -396,6 +396,13 @@ export default class MessagesPuppeteer {
this.log("Updated most recent message ID map:", this.mostRecentMessages) this.log("Updated most recent message ID map:", this.mostRecentMessages)
} }
async readImage(imageUrl) {
return await this.taskQueue.push(() =>
this.page.evaluate(
url => window.__mautrixController.readImage(url),
imageUrl))
}
async startObserving() { async startObserving() {
this.log("Adding chat list observer") this.log("Adding chat list observer")
await this.page.evaluate( await this.page.evaluate(
@ -496,7 +503,7 @@ export default class MessagesPuppeteer {
//await chatDetailArea.$(".MdTxtDesc02") || // 1:1 chat with custom title - get participant's real name //await chatDetailArea.$(".MdTxtDesc02") || // 1:1 chat with custom title - get participant's real name
participants = [{ participants = [{
id: id, id: id,
avatarURL: chatListInfo.iconURL, avatar: chatListInfo.icon,
name: chatListInfo.name, name: chatListInfo.name,
}] }]
} }