Skip to content

Commit

Permalink
Merge pull request #184 from mocchapi/embeds-prototype
Browse files Browse the repository at this point in the history
Add chat image embeds
  • Loading branch information
Dpeta authored Nov 9, 2024
2 parents 9bd9b17 + e490ce7 commit de24eed
Show file tree
Hide file tree
Showing 8 changed files with 294 additions and 4 deletions.
34 changes: 34 additions & 0 deletions convo.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from dataobjs import PesterHistory
from parsetools import convertTags, lexMessage, mecmd, colorBegin, colorEnd, smiledict
import parsetools
import embeds

PchumLog = logging.getLogger("pchumLogger")

Expand Down Expand Up @@ -382,6 +383,12 @@ def __init__(self, theme, parent=None):
QtCore.QUrl("smilies/%s" % (smiledict[k])),
"smilies/%s" % (smiledict[k]),
)

embeds.manager.embed_loading.connect(self.registerEmbed)
embeds.manager.embed_loaded.connect(self.showEmbed)
embeds.manager.embed_failed.connect(self.showEmbedError)
for embed in embeds.manager.get_embeds():
self.showEmbed(embed)
# self.mainwindow.animationSetting[bool].connect(self.animateChanged)

def addAnimation(self, url, fileName):
Expand All @@ -397,6 +404,33 @@ def addAnimation(self, url, fileName):
self.urls[movie] = url
movie.frameChanged.connect(movie.animate) # (int frameNumber)

def setResource(self, uri, pixmap):
try:
# PyQt6
resource_type = QtGui.QTextDocument.ResourceType.ImageResource.value
except AttributeError:
# PyQt5
resource_type = QtGui.QTextDocument.ResourceType.ImageResource
self.document().addResource(
resource_type,
QtCore.QUrl(uri),
pixmap,
)
self.setLineWrapColumnOrWidth(self.lineWrapColumnOrWidth())

def registerEmbed(self, url):
if embeds.manager.has_embed(url):
self.showEmbed(url)
else:
self.setResource(url, QtGui.QPixmap("img/loading_embed.png"))

def showEmbed(self, url):
self.setResource(url, embeds.manager.get_embed(url))

def showEmbedError(self, url, error=None):
# Sets the resource to generic "wuh oh failed" image
self.setResource(url, QtGui.QPixmap("img/embed_failed.png"))

"""
@QtCore.pyqtSlot(bool)
def animateChanged(self, animate):
Expand Down
118 changes: 118 additions & 0 deletions embeds.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
from collections import OrderedDict
import logging

try:
from PyQt6 import QtCore, QtGui, QtWidgets, QtNetwork
except ImportError:
print("PyQt5 fallback (thememanager.py)")
from PyQt5 import QtCore, QtGui, QtWidgets, QtNetwork

from theme_repo_manager import get_request


PchumLog = logging.getLogger("pchumLogger")

## embeds.py
# system for fetching & displaying image previews for image URLs
# has a single instance (singleton) called `manager` at the bottom of this file

## TODO?:
## - add "ignore filters" or whatever toggle in settings. slight security risk (leaking IP)


class EmbedsManager(QtCore.QObject):
cache = OrderedDict()
downloading = set()
max_items = 50

mainwindow = None

embed_loading = QtCore.pyqtSignal(str) # when the get request starts (url: str)
embed_loaded = QtCore.pyqtSignal(
str
) # when the embed is done downloading (url: str)
embed_purged = QtCore.pyqtSignal(
str
) # when the embed is cleared from memory (url: str)
embed_failed = QtCore.pyqtSignal(
str, str
) # when the embed fails to load (url: str, reason: str)

def __init__(self):
super().__init__()

def get_embeds(self):
"""Returns all cached embeds"""
return list(self.cache.keys())

def get_embed(self, url, placeholder=None):
"""Returns the QPixmap object of the embed image after fetching
Should be called when the embed_loaded signal has been emitted, OR after checking that has_embed == True
"""
if url in self.cache:
self.cache.move_to_end(url)
# make sure that embeds that were fetched a while ago but recently used do not get purged first
return self.cache.get(url, placeholder)

def has_embed(self, url):
return url in self.cache

def check_trustlist(self, url):
for item in self.mainwindow.userprofile.getTrustedDomains():
if url.startswith(item):
return True
return False

def fetch_embed(self, url, ignore_cache=False):
"""Downloads a new embed if it does not exist yet"""

if not self.check_trustlist(url):
PchumLog.warning(
"Requested embed fetch of %s denied because it does not match the trust filter.",
url,
)
return

if not ignore_cache and self.has_embed(url):
PchumLog.debug(
"Requested embed fetch of %s, but it was already fetched", url
)
return
elif url in self.downloading:
PchumLog.debug(
"Requested embed fetch of %s, but it is already being fetched", url
)
return

PchumLog.info("Fetching embed of %s", url)

self.downloading.add(url)
# Track which embeds are downloading so we dont do double-fetches

self.embed_loading.emit(url)
reply = get_request(url)
reply.finished.connect(lambda: self._on_request_finished(reply, url))

def _on_request_finished(self, reply, url):
"""Callback, called when an embed has finished downloading"""
self.downloading.remove(url)
if reply.error() == QtNetwork.QNetworkReply.NetworkError.NoError:
PchumLog.info(f"Finished fetching embed {url}")

pixmap = QtGui.QPixmap()
pixmap.loadFromData(reply.readAll())

self.cache[url] = pixmap
self.embed_loaded.emit(url)

if len(self.cache) > self.max_items:
to_purge = list(self.cache.keys())[0]
PchumLog.debug("Purging embed %s", to_purge)
self.embed_purged.emit(to_purge)
del self.cache[to_purge]
else:
PchumLog.error("Error fetching embed %s: %s", url, reply.error())
self.embed_failed.emit(url, str(reply.error()))


manager = EmbedsManager()
Binary file added img/embed_failed.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added img/loading_embed.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
63 changes: 63 additions & 0 deletions menus.py
Original file line number Diff line number Diff line change
Expand Up @@ -1384,6 +1384,63 @@ def __init__(self, config, theme, parent):
self.userlinkscheck.setChecked(self.config.disableUserLinks())
self.userlinkscheck.setVisible(False)

self.label_trusteddomains = QtWidgets.QLabel("Trusted image domains:")
self.list_trusteddomains = QtWidgets.QListWidget()
self.list_trusteddomains.addItems(parent.userprofile.getTrustedDomains())
self.hbox_trusteddomains_buttons = QtWidgets.QHBoxLayout()
self.button_trusteddomains_add = QtWidgets.QPushButton("Add")
self.button_trusteddomains_remove = QtWidgets.QPushButton("Remove")
self.hbox_trusteddomains_buttons.addWidget(self.button_trusteddomains_add)
self.hbox_trusteddomains_buttons.addWidget(self.button_trusteddomains_remove)
self.label_trusteddomains_info = QtWidgets.QLabel(
"When an image link is sent in a conversation that belongs to one of these domains, pesterchum will embed the image alongside the message in the log"
)
self.label_trusteddomains_info.setWordWrap(True)

def _on_button_trusteddomains_remove_pressed():
selected_idx = self.list_trusteddomains.currentRow()
if selected_idx >= 0:
self.list_trusteddomains.takeItem(selected_idx)

def _on_button_trusteddomains_add_pressed():
# When "add" is pressed, open a dialog where the user can enter 1 domain
schema = {"label": "Domain:", "inputname": "value"}

result = MultiTextDialog("ENTER DOMAIN", self, schema).getText()
if result is None:
return
domain = result["value"]

if not "." in domain:
# No TLD (.com, .org, etc)
# not a valid domain
errbox = QtWidgets.QMessageBox(self)
errbox.setText("Not a valid domain!")
errbox.setInformativeText(
"You are missing the TLD (.com, .org, etc etc)"
)
errbox.exec()
return

if not (domain.startswith("https://") or domain.startswith("http://")):
# Missing protocol, but thats fine
# This also means you'd need two entries for http & https version of a website
# but we stan https everywhere in this house so we ball
domain = "https://" + domain
if domain.count("/") < 3:
# append a '/' to the end if there is no '/' anywhere after the TLD
# this is to prevent something like `https://example.org` to also match with `https://example.org.mynefariouswebsite.com'
# IE, 'https://example.org' becomes 'https://example.org/', but 'https://example.org/beap' is left as-is
domain += "/"
self.list_trusteddomains.addItem(domain)

self.button_trusteddomains_add.clicked.connect(
_on_button_trusteddomains_add_pressed
)
self.button_trusteddomains_remove.clicked.connect(
_on_button_trusteddomains_remove_pressed
)

# Will add ability to turn off groups later
# self.groupscheck = QtGui.QCheckBox("Use Groups", self)
# self.groupscheck.setChecked(self.config.useGroups())
Expand Down Expand Up @@ -1587,6 +1644,12 @@ def reset_themeBox():
layout_chat.addWidget(self.animationscheck)
layout_chat.addWidget(animateLabel)
layout_chat.addWidget(self.randomscheck)

layout_chat.addWidget(self.label_trusteddomains)
layout_chat.addWidget(self.list_trusteddomains)
layout_chat.addLayout(self.hbox_trusteddomains_buttons)
layout_chat.addWidget(self.label_trusteddomains_info)

# Re-enable these when it's possible to disable User and Memo links
# layout_chat.addWidget(hr)
# layout_chat.addWidget(QtGui.QLabel("User and Memo Links"))
Expand Down
38 changes: 38 additions & 0 deletions parsetools.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
from pyquirks import PythonQuirks
from scripts.services import BOTNAMES

import embeds

PchumLog = logging.getLogger("pchumLogger")

# I'll clean up the things that are no longer needed once the transition is
Expand All @@ -42,6 +44,12 @@
_alternian_begin = re.compile(r"<alt>") # Matches get set to alternian font
_alternian_end = re.compile(r"</alt>")


_embedre = re.compile(
r"(?i)(?:^|(?<=\s))(?:(?:https?):\/\/)[^\s]+(?:\.png|\.jpg|\.jpeg)(?:\?[^\s]+)?"
)


quirkloader = ScriptQuirks()
_functionre = None

Expand Down Expand Up @@ -163,6 +171,33 @@ def convert(self, format):
return self.string


class embedlink(lexercon.Chunk):
domain_trusted = False

def __init__(self, url):
self.url = url
self.domain_trusted = embeds.manager.check_trustlist(self.url)
if self.domain_trusted:
embeds.manager.fetch_embed(url)

def convert(self, format):
if format == "html":
# Only add the showable image tag if the domain is trusted & thus will actually be fetched
# Otherwise would show "fetching embed..." forever
if self.domain_trusted:
# <a> tag to make it clickable, <img> tag to make it show the image (actual image resource is added after parsing)
return (
"<a href='%s'>%s</a><br><a href='%s'><img alt='%s' src='%s' width=300></a>"
% (self.url, self.url, self.url, self.url, self.url)
)
else:
return "<a href='%s'>%s</a>" % (self.url, self.url)
elif format == "bbcode":
return f"[url]{self.url}[/url]"
else:
return self.url


class hyperlink(lexercon.Chunk):
def __init__(self, string):
self.string = string
Expand Down Expand Up @@ -214,6 +249,7 @@ def __init__(self, string, img):
self.img = img

def convert(self, format):
# raise Exception(format)
if format == "html":
return self.string
elif format == "bbcode":
Expand Down Expand Up @@ -258,6 +294,7 @@ def __init__(self, string):
self.string = string

def convert(self, format):
# print("SMILEY:: ", format)
if format == "html":
return "<img src='smilies/{}' alt='{}' title='{}' />".format(
smiledict[self.string],
Expand Down Expand Up @@ -318,6 +355,7 @@ def lexMessage(string: str):
# actually use it, save for Chumdroid...which shouldn't.
# When I change out parsers, I might add it back in.
##(formatBegin, _format_begin), (formatEnd, _format_end),
(embedlink, _embedre),
(imagelink, _imgre),
(hyperlink, _urlre),
(memolex, _memore),
Expand Down
15 changes: 15 additions & 0 deletions pesterchum.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
from randomer import RandomHandler, RANDNICK
from toast import PesterToastMachine, PesterToast
from scripts.services import SERVICES, CUSTOMBOTS, BOTNAMES, translate_nickserv_msg
import embeds

try:
from PyQt6 import QtCore, QtGui, QtWidgets, QtMultimedia
Expand Down Expand Up @@ -1260,9 +1261,12 @@ def __init__(self, options, parent=None, app=None):
# Part 1 :(
try:
if self.config.defaultprofile():
# "defaultprofile" config setting is set
# load is here
self.userprofile = userProfile(self.config.defaultprofile())
self.theme = self.userprofile.getTheme()
else:
# Generate a new profile (likely this is the first-run)
self.userprofile = userProfile(
PesterProfile(
"pesterClient%d" % (random.randint(100, 999)),
Expand Down Expand Up @@ -1353,6 +1357,8 @@ def __init__(self, options, parent=None, app=None):

self.move(100, 100)

embeds.manager.mainwindow = self ## We gotta get a reference to the user profile from somewhere since its not global. oh well

talk = QAction(self.theme["main/menus/client/talk"], self)
self.talk = talk
talk.triggered.connect(self.openChat)
Expand Down Expand Up @@ -3096,6 +3102,15 @@ def updateOptions(self):
self.config.set("time12Format", False)
secondssetting = self.optionmenu.secondscheck.isChecked()
self.config.set("showSeconds", secondssetting)

# trusted domains
trusteddomains = []
for i in range(self.optionmenu.list_trusteddomains.count()):
trusteddomains.append(
self.optionmenu.list_trusteddomains.item(i).text()
)
self.userprofile.setTrustedDomains(trusteddomains)

# groups
# groupssetting = self.optionmenu.groupscheck.isChecked()
# self.config.set("useGroups", groupssetting)
Expand Down
Loading

0 comments on commit de24eed

Please sign in to comment.