Skip to content

Commit

Permalink
feat: improve the chat (#2453)
Browse files Browse the repository at this point in the history
* separate channel for service messages
* enhance collaborator list by integrating user avatars and active status indicator
* detailed service messages : moved waypoint, inserted waypoint, deleted waypoint
  • Loading branch information
workaryangupta authored Aug 23, 2024
1 parent 0306cbb commit 91e9bd5
Show file tree
Hide file tree
Showing 14 changed files with 198 additions and 47 deletions.
3 changes: 2 additions & 1 deletion mslib/mscolab/file_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -385,7 +385,8 @@ def get_authorized_users(self, op_id):
users = []
for permission in permissions:
user = User.query.filter_by(id=permission.u_id).first()
users.append({"username": user.username, "access_level": permission.access_level})
users.append({"username": user.username, "access_level": permission.access_level,
"id": permission.u_id})
return users

def save_file(self, op_id, content, user, comment=""):
Expand Down
7 changes: 7 additions & 0 deletions mslib/mscolab/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -530,6 +530,13 @@ def authorized_users():
return json.dumps({"users": fm.get_authorized_users(int(op_id))})


@APP.route('/active_users', methods=["GET"])
@verify_user
def active_users():
op_id = request.args.get('op_id', request.form.get('op_id', None))
return jsonify(active_users=list(sockio.sm.active_users_per_operation[int(op_id)]))


@APP.route('/operations', methods=['GET'])
@verify_user
def get_operations():
Expand Down
3 changes: 2 additions & 1 deletion mslib/mscolab/sockets_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -275,14 +275,15 @@ def handle_file_save(self, json_req):
op_id = json_req['op_id']
content = json_req['content']
comment = json_req.get('comment', "")
messageText = json_req.get('messageText')
user = User.verify_auth_token(json_req['token'])
if user is not None:
# when the socket connection is expired this in None and also on wrong tokens
perm = self.permission_check_emit(user.id, int(op_id))
# if permission is correct and file saved properly
if perm and self.fm.save_file(int(op_id), content, user, comment):
# send service message
message_ = f"[service message] **{user.username}** saved changes"
message_ = f"[service message] **{user.username}** saved changes. {messageText}"
new_message = self.cm.add_message(user, message_, str(op_id), message_type=MessageType.SYSTEM_MESSAGE)
new_message_dict = get_message_dict(new_message)
socketio.emit('chat-message-client', json.dumps(new_message_dict))
Expand Down
20 changes: 18 additions & 2 deletions mslib/msui/flighttrack.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,9 @@ class WaypointsTableModel(QtCore.QAbstractTableModel):
flight performance calculations.
"""

# Signal emitted when a waypoint is moved, inserted or deleted
changeMessageSignal = QtCore.pyqtSignal(str)

def __init__(self, name="", filename=None, waypoints=None, mscolab_mode=False, data_dir=mss_default.mss_dir,
xml_content=None):
super().__init__()
Expand Down Expand Up @@ -352,6 +355,7 @@ def setData(self, index, value, role=QtCore.Qt.EditRole, update=True):
waypoint.location = loc[1]
# A change of position requires an update of the distances.
if update:
self.changeMessageSignal.emit(f'Moved waypoint {index.row()}')
self.update_distances(index.row())
# Notify the views that items between the edited item and
# the distance item of the corresponding waypoint have been
Expand All @@ -378,6 +382,7 @@ def setData(self, index, value, role=QtCore.Qt.EditRole, update=True):
waypoint.lat, waypoint.lon = loc[0]
waypoint.location = loc[1]
if update:
self.changeMessageSignal.emit(f'Moved waypoint {index.row()}')
self.update_distances(index.row())
index2 = self.createIndex(index.row(), LOCATION)
elif column == FLIGHTLEVEL:
Expand All @@ -394,6 +399,7 @@ def setData(self, index, value, role=QtCore.Qt.EditRole, update=True):
waypoint.flightlevel = flightlevel
waypoint.pressure = pressure
if update:
self.changeMessageSignal.emit(f'Moved waypoint {index.row()}')
self.update_distances(index.row())
# need to notify view of the second item that has been
# changed as well.
Expand All @@ -415,6 +421,7 @@ def setData(self, index, value, role=QtCore.Qt.EditRole, update=True):
waypoint.pressure = pressure
waypoint.flightlevel = flightlevel
if update:
self.changeMessageSignal.emit(f'Moved waypoint {index.row()}')
self.update_distances(index.row())
index2 = self.createIndex(index.row(), FLIGHTLEVEL)
else:
Expand All @@ -427,7 +434,7 @@ def setData(self, index, value, role=QtCore.Qt.EditRole, update=True):
return False

def insertRows(self, position, rows=1, index=QtCore.QModelIndex(),
waypoints=None):
waypoints=None, hexagonCreated=False):
"""
Insert waypoint; overrides the corresponding QAbstractTableModel
method.
Expand All @@ -437,6 +444,9 @@ def insertRows(self, position, rows=1, index=QtCore.QModelIndex(),

assert len(waypoints) == rows, (waypoints, rows)

savedChangeMessage = "Hexagon created." if hexagonCreated else ("Inserted a new waypoint.")
self.changeMessageSignal.emit(savedChangeMessage)

self.beginInsertRows(QtCore.QModelIndex(), position,
position + rows - 1)
for row, wp in enumerate(waypoints):
Expand All @@ -447,11 +457,17 @@ def insertRows(self, position, rows=1, index=QtCore.QModelIndex(),
self.modified = True
return True

def removeRows(self, position, rows=1, index=QtCore.QModelIndex()):
def removeRows(self, position, rows=1, index=QtCore.QModelIndex(), hexagonDeleted=False):
"""
Remove waypoint; overrides the corresponding QAbstractTableModel
method.
"""
if hexagonDeleted:
savedChangeMessage = f"Deleted waypoints {position}-{position + rows - 1}."
else:
savedChangeMessage = f"Deleted waypoint {position}."
self.changeMessageSignal.emit(savedChangeMessage)

# beginRemoveRows emits rowsAboutToBeRemoved(index, first, last).
self.beginRemoveRows(QtCore.QModelIndex(), position,
position + rows - 1)
Expand Down
4 changes: 2 additions & 2 deletions mslib/msui/hexagon_dockwidget.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ def _add_hexagon(self):
waypoints.append(
ft.Waypoint(lon=float(point[1]), lat=float(point[0]),
flightlevel=float(flightlevel), comments=f"Hexagon {(i + 1):d}"))
waypoints_model.insertRows(row, rows=len(waypoints), waypoints=waypoints)
waypoints_model.insertRows(row, rows=len(waypoints), waypoints=waypoints, hexagonCreated=True)
index = waypoints_model.index(row, 0)
table_view.setCurrentIndex(index)
table_view.resizeRowsToContents()
Expand Down Expand Up @@ -156,7 +156,7 @@ def _remove_hexagon(self):
QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No,
QtWidgets.QMessageBox.Yes)
if sel == QtWidgets.QMessageBox.Yes:
waypoints_model.removeRows(row_min, rows=7)
waypoints_model.removeRows(row_min, rows=7, hexagonDeleted=True)
else:
raise HexagonException("Cannot remove hexagon, please select a hexagon "
"waypoint ('Hexagon x' in comments field)")
Expand Down
18 changes: 17 additions & 1 deletion mslib/msui/mscolab.py
Original file line number Diff line number Diff line change
Expand Up @@ -575,6 +575,9 @@ def __init__(self, parent=None, data_dir=None):
# Gravatar image path
self.gravatar = None

# Service message text for flight-track changes (waypoints inserted, moved or deleted)
self.lastChangeMessage = ""

# set data dir, uri
if data_dir is None:
self.data_dir = config_loader(dataset="mss_dir")
Expand Down Expand Up @@ -1157,6 +1160,7 @@ def update_views(self):
initial_waypoints = [ft.Waypoint(location=locations[0]), ft.Waypoint(location=locations[1])]
waypoints_model = ft.WaypointsTableModel(name="", waypoints=initial_waypoints)
self.waypoints_model = waypoints_model
self.waypoints_model.changeMessageSignal.connect(self.handle_change_message)
self.reload_view_windows()

def close_external_windows(self):
Expand Down Expand Up @@ -1441,6 +1445,7 @@ def create_local_operation_file(self):

def reload_local_wp(self):
self.waypoints_model = ft.WaypointsTableModel(filename=self.local_ftml_file, data_dir=self.data_dir)
self.waypoints_model.changeMessageSignal.connect(self.handle_change_message)
self.waypoints_model.dataChanged.connect(self.handle_waypoints_changed)
self.reload_view_windows()

Expand Down Expand Up @@ -1482,6 +1487,7 @@ def fetch_wp_mscolab(self):
xml_content = self.merge_dialog.get_values()
if xml_content is not None:
self.waypoints_model = ft.WaypointsTableModel(xml_content=xml_content)
self.waypoints_model.changeMessageSignal.connect(self.handle_change_message)
self.waypoints_model.save_to_ftml(self.local_ftml_file)
self.waypoints_model.dataChanged.connect(self.handle_waypoints_changed)
self.reload_view_windows()
Expand All @@ -1504,6 +1510,7 @@ def save_wp_mscolab(self, comment=None):
if xml_content is not None:
self.conn.save_file(self.token, self.active_op_id, xml_content, comment=comment)
self.waypoints_model = ft.WaypointsTableModel(xml_content=xml_content)
self.waypoints_model.changeMessageSignal.connect(self.handle_change_message)
self.waypoints_model.save_to_ftml(self.local_ftml_file)
self.waypoints_model.dataChanged.connect(self.handle_waypoints_changed)
self.reload_view_windows()
Expand Down Expand Up @@ -1686,6 +1693,10 @@ def update_active_user_label(self, op_id, count):
if self.active_op_id == op_id:
self.ui.userCountLabel.setText(f"Active Users: {count}")

@QtCore.pyqtSlot(str)
def handle_change_message(self, message):
self.lastChangeMessage = message

def show_categories_to_ui(self, ops=None):
"""
adds the list of operation categories to the UI
Expand Down Expand Up @@ -2001,6 +2012,7 @@ def load_wps_from_server(self):
xml_content = self.request_wps_from_server()
if xml_content is not None:
self.waypoints_model = ft.WaypointsTableModel(xml_content=xml_content)
self.waypoints_model.changeMessageSignal.connect(self.handle_change_message)
self.waypoints_model.name = self.active_operation_name
self.waypoints_model.dataChanged.connect(self.handle_waypoints_changed)

Expand All @@ -2025,7 +2037,10 @@ def handle_waypoints_changed(self):
self.waypoints_model.save_to_ftml(self.local_ftml_file)
else:
xml_content = self.waypoints_model.get_xml_content()
self.conn.save_file(self.token, self.active_op_id, xml_content, comment=None)
self.conn.save_file(self.token, self.active_op_id, xml_content, comment=None,
messageText=self.lastChangeMessage)
# Reset the last change message to make sure that it is used only once
self.lastChangeMessage = ""
else:
show_popup(self.ui, "Error", "Your Connection is expired. New Login required!")
self.logout()
Expand Down Expand Up @@ -2072,6 +2087,7 @@ def handle_import_msc(self, file_path, extension, function, pickertype):
xml_content = xml_doc.toprettyxml(indent=" ", newl="\n")
self.waypoints_model.dataChanged.disconnect(self.handle_waypoints_changed)
self.waypoints_model = model
self.waypoints_model.changeMessageSignal.connect(self.handle_change_message)
self.handle_waypoints_changed()
self.waypoints_model.dataChanged.connect(self.handle_waypoints_changed)
self.reload_view_windows()
Expand Down
77 changes: 67 additions & 10 deletions mslib/msui/mscolab_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ def __init__(self, token, op_id, user, operation_name, access_level, conn, paren
self.conn.signal_message_reply_receive.connect(self.handle_incoming_message_reply)
self.conn.signal_message_edited.connect(self.handle_message_edited)
self.conn.signal_message_deleted.connect(self.handle_deleted_message)
self.conn.signal_update_collaborator_list.connect(self.update_user_list)
# Set Label text
self.set_label_text()
# Hide Edit Message section
Expand Down Expand Up @@ -327,19 +328,64 @@ def edit_message(self):
# API REQUESTS
def load_users(self):
# load users to side-tab here
# make request to get users
# make requests to get all users and active users of the operation
data = {
"token": self.token,
"op_id": self.op_id
}
url = urljoin(self.mscolab_server_url, 'authorized_users')
r = requests.get(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout")))
if r.text != "False":
users_url = urljoin(self.mscolab_server_url, 'authorized_users')
active_users_url = urljoin(self.mscolab_server_url, 'active_users')

# Fetch both authorized and active users
users_response = requests.get(users_url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout")))
active_response = requests.get(active_users_url, data=data,
timeout=tuple(config_loader(dataset="MSCOLAB_timeout")))

if users_response != "False":
self.collaboratorsList.clear()
users = r.json()["users"]
users = users_response.json()["users"]
active_users = set(active_response.json()["active_users"])
for user in users:
item = QtWidgets.QListWidgetItem(f'{user["username"]} - {user["access_level"]}',
parent=self.collaboratorsList)
display_text = f'{user["username"]} - {user["access_level"]}'
item = QtWidgets.QListWidgetItem(display_text, parent=self.collaboratorsList)

# Pixmap for icon i.e. profile image
url = urljoin(self.mscolab_server_url, 'fetch_profile_image')
data = {
"user_id": str(user["id"]),
"token": self.token
}
response = requests.get(url, data=data)
pixmap = QtGui.QPixmap()
if response.status_code == 200:
# pixmap = QtGui.QPixmap()
pixmap.loadFromData(response.content)
else:
first_alphabet = user["username"][0].lower() if user["username"] else "default"
default_avatar_path = f":/gravatars/default-gravatars/{first_alphabet}.png"
pixmap.load(default_avatar_path)

# Scale pixmap to a standard size
icon_size = QtCore.QSize(50, 50)
pixmap = pixmap.scaled(icon_size, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation)

# Load avatar and overlay green dot on profile image pixmap if user is active
if user["id"] in active_users:
painter = QtGui.QPainter(pixmap)
painter.setBrush(QtGui.QColor(0, 230, 0, 230)) # RGBA
# Set a thin pen for the border around green dot
pen = QtGui.QPen(QtGui.QColor(0, 0, 0), 3) # (border color, width)
painter.setPen(pen)
# Draw circle at bottom-right corner
diameter = 13 # Size of the dot
margin = -2 # Distance from the edges
position = QtCore.QPoint(pixmap.width() - diameter - margin, pixmap.height() - diameter - margin)
painter.drawEllipse(position, diameter, diameter)
painter.end()

# Set the icon
icon = QtGui.QIcon(pixmap)
item.setIcon(icon)
self.collaboratorsList.addItem(item)
else:
show_popup(self, "Error", "Session expired, new login required")
Expand All @@ -363,17 +409,24 @@ def load_all_messages(self):
for message in messages:
self.render_new_message(message, scroll=False)
self.messageList.scrollToBottom()
self.serviceMessageList.scrollToBottom()
else:
show_popup(self, "Error", "Session expired, new login required")

def render_new_message(self, message, scroll=True):
message_item = MessageItem(message, self)
list_widget_item = QtWidgets.QListWidgetItem(self.messageList)
list_widget_item = QtWidgets.QListWidgetItem()
list_widget_item.setSizeHint(message_item.sizeHint())
self.messageList.addItem(list_widget_item)
self.messageList.setItemWidget(list_widget_item, message_item)
# Check if the message is a service message or a normal message and add to its corresponding list
if message['message_type'] == MessageType.SYSTEM_MESSAGE:
self.serviceMessageList.addItem(list_widget_item)
self.serviceMessageList.setItemWidget(list_widget_item, message_item)
else:
self.messageList.addItem(list_widget_item)
self.messageList.setItemWidget(list_widget_item, message_item)
if scroll:
self.messageList.scrollToBottom()
self.serviceMessageList.scrollToBottom()

# SOCKET HANDLERS
@QtCore.pyqtSlot(int)
Expand Down Expand Up @@ -437,6 +490,10 @@ def handle_deleted_message(self, message):
self.messageList.takeItem(i)
break

@QtCore.pyqtSlot()
def update_user_list(self):
self.load_users()

def closeEvent(self, event):
self.viewCloses.emit()

Expand Down
10 changes: 0 additions & 10 deletions mslib/msui/qt5/ui_mainwindow.py
Original file line number Diff line number Diff line change
Expand Up @@ -389,13 +389,3 @@ def retranslateUi(self, MSUIMainWindow):
self.actionLeaveOperation.setText(_translate("MSUIMainWindow", "&Leave Operation"))
self.actionArchiveOperation.setText(_translate("MSUIMainWindow", "Archive Operation"))
self.actionChangeCategory.setText(_translate("MSUIMainWindow", "Change Category"))


if __name__ == "__main__":
import sys
app = QtWidgets.QApplication(sys.argv)
MSUIMainWindow = QtWidgets.QMainWindow()
ui = Ui_MSUIMainWindow()
ui.setupUi(MSUIMainWindow)
MSUIMainWindow.show()
sys.exit(app.exec_())
Loading

0 comments on commit 91e9bd5

Please sign in to comment.