Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/replies #28

Draft
wants to merge 14 commits into
base: master
Choose a base branch
from
Draft
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ __Functionality-related__

-:white_check_mark: Upload the file first, send the message later (async uploads) - potential for file ref re-use later

-:white_check_mark: Reply to messages
... and more


Expand Down Expand Up @@ -167,7 +168,7 @@ Frontend (example app) & backend
7. Last seen
8. Send photo
9. :white_check_mark: Send file
10. Reply to message
10. :white_check_mark: Reply to message
11. Delete message
12. Forward message
13. Search for dialog (username)
Expand Down
2 changes: 1 addition & 1 deletion django_private_chat2/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
class MessageModelAdmin(ModelAdmin):
readonly_fields = ('created', 'modified',)
search_fields = ('id', 'text', 'sender__pk', 'recipient__pk')
list_display = ('id', 'sender', 'recipient', 'text', 'file', 'read')
list_display = ('id', 'sender', 'recipient', 'text', 'file', 'read', 'reply_to')
list_display_links = ('id',)
list_filter = ('sender', 'recipient')
date_hierarchy = 'created'
Expand Down
40 changes: 30 additions & 10 deletions django_private_chat2/consumers/chat_consumer.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@
from .db_operations import get_groups_to_add, get_unread_count, get_user_by_pk, get_file_by_id, get_message_by_id, \
save_file_message, save_text_message, mark_message_as_read
from .message_types import MessageTypes, MessageTypeMessageRead, MessageTypeFileMessage, MessageTypeTextMessage, \
OutgoingEventMessageRead, OutgoingEventNewTextMessage, OutgoingEventNewUnreadCount, OutgoingEventMessageIdCreated,\
OutgoingEventNewFileMessage, OutgoingEventIsTyping, OutgoingEventStoppedTyping, OutgoingEventWentOnline, OutgoingEventWentOffline
OutgoingEventMessageRead, OutgoingEventNewTextMessage, OutgoingEventNewUnreadCount, OutgoingEventMessageIdCreated, \
OutgoingEventNewFileMessage, OutgoingEventIsTyping, OutgoingEventStoppedTyping, OutgoingEventWentOnline, \
OutgoingEventWentOffline

from .errors import ErrorTypes, ErrorDescription
from django_private_chat2.models import MessageModel, UploadedFile
Expand All @@ -20,14 +21,16 @@
TEXT_MAX_LENGTH = getattr(settings, 'TEXT_MAX_LENGTH', 65535)
UNAUTH_REJECT_CODE: int = 4001


class ChatConsumer(AsyncWebsocketConsumer):
async def _after_message_save(self, msg: MessageModel, rid: int, user_pk: str):
ev = OutgoingEventMessageIdCreated(random_id=rid, db_id=msg.id)._asdict()
logger.info(f"Message with id {msg.id} saved, firing events to {user_pk} & {self.group_name}")
await self.channel_layer.group_send(user_pk, ev)
await self.channel_layer.group_send(self.group_name, ev)
new_unreads = await get_unread_count(self.group_name, user_pk)
await self.channel_layer.group_send(user_pk, OutgoingEventNewUnreadCount(sender=self.group_name, unread_count=new_unreads)._asdict())
await self.channel_layer.group_send(user_pk, OutgoingEventNewUnreadCount(sender=self.group_name,
unread_count=new_unreads)._asdict())

async def connect(self):
# TODO:
Expand All @@ -46,7 +49,8 @@ async def connect(self):
logger.info(f"User {self.user.pk} connected, sending 'user_went_online' to {dialogs} dialog groups")
for d in dialogs: # type: int
if str(d) != self.group_name:
await self.channel_layer.group_send(str(d), OutgoingEventWentOnline(user_pk=str(self.user.pk))._asdict())
await self.channel_layer.group_send(str(d),
OutgoingEventWentOnline(user_pk=str(self.user.pk))._asdict())
else:
logger.info(f"Rejecting unauthenticated user with code {UNAUTH_REJECT_CODE}")
await self.close(code=UNAUTH_REJECT_CODE)
Expand All @@ -63,7 +67,8 @@ async def disconnect(self, close_code):
dialogs = await get_groups_to_add(self.user)
logger.info(f"User {self.user.pk} disconnected, sending 'user_went_offline' to {dialogs} dialog groups")
for d in dialogs:
await self.channel_layer.group_send(str(d), OutgoingEventWentOffline(user_pk=str(self.user.pk))._asdict())
await self.channel_layer.group_send(str(d),
OutgoingEventWentOffline(user_pk=str(self.user.pk))._asdict())

async def handle_received_message(self, msg_type: MessageTypes, data: Dict[str, str]) -> Optional[ErrorDescription]:
logger.info(f"Received message type {msg_type.name} from user {self.group_name} with data {data}")
Expand All @@ -78,15 +83,17 @@ async def handle_received_message(self, msg_type: MessageTypes, data: Dict[str,
logger.info(f"User {self.user.pk} is typing, sending 'is_typing' to {dialogs} dialog groups")
for d in dialogs:
if str(d) != self.group_name:
await self.channel_layer.group_send(str(d), OutgoingEventIsTyping(user_pk=str(self.user.pk))._asdict())
await self.channel_layer.group_send(str(d),
OutgoingEventIsTyping(user_pk=str(self.user.pk))._asdict())
return None
elif msg_type == MessageTypes.TypingStopped:
dialogs = await get_groups_to_add(self.user)
logger.info(
f"User {self.user.pk} has stopped typing, sending 'stopped_typing' to {dialogs} dialog groups")
for d in dialogs:
if str(d) != self.group_name:
await self.channel_layer.group_send(str(d), OutgoingEventStoppedTyping(user_pk=str(self.user.pk))._asdict())
await self.channel_layer.group_send(str(d), OutgoingEventStoppedTyping(
user_pk=str(self.user.pk))._asdict())
return None
elif msg_type == MessageTypes.MessageRead:
data: MessageTypeMessageRead
Expand Down Expand Up @@ -152,6 +159,7 @@ async def handle_received_message(self, msg_type: MessageTypes, data: Dict[str,
file_id = data['file_id']
user_pk = data['user_pk']
rid = data['random_id']
reply_to: Optional[int] = data['reply_to'] if 'reply_to' in data else None
# We can't send the message right away like in the case with text message
# because we don't have the file url.
file: Optional[UploadedFile] = await get_file_by_id(file_id)
Expand All @@ -161,11 +169,16 @@ async def handle_received_message(self, msg_type: MessageTypes, data: Dict[str,
else:
recipient: Optional[AbstractBaseUser] = await get_user_by_pk(user_pk)
logger.info(f"DB check if user {user_pk} exists resulted in {recipient}")
reply_to_msg: Optional[MessageModel] = None
if reply_to is not None:
reply_to_msg = await get_message_by_id(reply_to)
if not reply_to_msg:
return ErrorTypes.InvalidReplyMsgId, f"Message with id {reply_to} was not found"
if not recipient:
return ErrorTypes.InvalidUserPk, f"User with pk {user_pk} does not exist"
else:
logger.info(f"Will save file message from {self.user} to {recipient}")
msg = await save_file_message(file, from_=self.user, to=recipient)
msg = await save_file_message(file, from_=self.user, to=recipient, reply_to=reply_to_msg)
await self._after_message_save(msg, rid=rid, user_pk=user_pk)
logger.info(f"Sending file message for file {file_id} from {self.user} to {recipient}")
# We don't need to send random_id here because we've already saved the file to db
Expand All @@ -175,8 +188,8 @@ async def handle_received_message(self, msg_type: MessageTypes, data: Dict[str,
file=serialize_file_model(file),
sender=self.group_name,
receiver=user_pk,
reply_to=reply_to,
sender_username=self.sender_username)._asdict())

elif msg_type == MessageTypes.TextMessage:
data: MessageTypeTextMessage
if 'text' not in data:
Expand All @@ -201,6 +214,7 @@ async def handle_received_message(self, msg_type: MessageTypes, data: Dict[str,
text = data['text']
user_pk = data['user_pk']
rid = data['random_id']
reply_to: Optional[int] = data['reply_to'] if 'reply_to' in data else None
# first we send data to channel layer to not perform any synchronous operations,
# and only after we do sync DB stuff
# We need to create a 'random id' - a temporary id for the message, which is not yet
Expand All @@ -212,14 +226,20 @@ async def handle_received_message(self, msg_type: MessageTypes, data: Dict[str,
text=text,
sender=self.group_name,
receiver=user_pk,
reply_to=reply_to,
sender_username=self.sender_username)._asdict())
recipient: Optional[AbstractBaseUser] = await get_user_by_pk(user_pk)
logger.info(f"DB check if user {user_pk} exists resulted in {recipient}")
reply_to_msg: Optional[MessageModel] = None
if reply_to is not None:
reply_to_msg = await get_message_by_id(reply_to)
if not reply_to_msg:
return ErrorTypes.InvalidReplyMsgId, f"Message with id {reply_to} was not found"
if not recipient:
return ErrorTypes.InvalidUserPk, f"User with pk {user_pk} does not exist"
else:
logger.info(f"Will save text message from {self.user} to {recipient}")
msg = await save_text_message(text, from_=self.user, to=recipient)
msg = await save_text_message(text, from_=self.user, to=recipient, reply_to=reply_to_msg)
await self._after_message_save(msg, rid=rid, user_pk=user_pk)

# Receive message from WebSocket
Expand Down
8 changes: 4 additions & 4 deletions django_private_chat2/consumers/db_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,10 @@ def get_unread_count(sender, recipient) -> Awaitable[int]:


@database_sync_to_async
def save_text_message(text: str, from_: AbstractBaseUser, to: AbstractBaseUser) -> Awaitable[MessageModel]:
return MessageModel.objects.create(text=text, sender=from_, recipient=to)
def save_text_message(text: str, from_: AbstractBaseUser, to: AbstractBaseUser, reply_to: Optional[MessageModel] = None) -> Awaitable[MessageModel]:
return MessageModel.objects.create(text=text, sender=from_, recipient=to, reply_to=reply_to)


@database_sync_to_async
def save_file_message(file: UploadedFile, from_: AbstractBaseUser, to: AbstractBaseUser) -> Awaitable[MessageModel]:
return MessageModel.objects.create(file=file, sender=from_, recipient=to)
def save_file_message(file: UploadedFile, from_: AbstractBaseUser, to: AbstractBaseUser, reply_to: Optional[MessageModel] = None) -> Awaitable[MessageModel]:
return MessageModel.objects.create(file=file, sender=from_, recipient=to, reply_to=reply_to)
1 change: 1 addition & 0 deletions django_private_chat2/consumers/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ class ErrorTypes(enum.IntEnum):
InvalidRandomId = 5
FileMessageInvalid = 6
FileDoesNotExist = 7
InvalidReplyMsgId = 8


ErrorDescription = Tuple[ErrorTypes, str]
6 changes: 6 additions & 0 deletions django_private_chat2/consumers/message_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ class MessageTypeTextMessage(TypedDict):
text: str
user_pk: str
random_id: int
reply_to: Optional[int]


class MessageTypeMessageRead(TypedDict):
Expand All @@ -25,6 +26,7 @@ class MessageTypeFileMessage(TypedDict):
file_id: str
user_pk: str
random_id: int
reply_to: Optional[int]


class MessageTypes(enum.IntEnum):
Expand Down Expand Up @@ -64,6 +66,7 @@ class OutgoingEventNewTextMessage(NamedTuple):
sender: str
receiver: str
sender_username: str
reply_to: Optional[int]
type: str = "new_text_message"

def to_json(self) -> str:
Expand All @@ -73,6 +76,7 @@ def to_json(self) -> str:
"text": self.text,
"sender": self.sender,
"receiver": self.receiver,
"reply_to": self.reply_to,
"sender_username": self.sender_username,
})

Expand All @@ -83,6 +87,7 @@ class OutgoingEventNewFileMessage(NamedTuple):
sender: str
receiver: str
sender_username: str
reply_to: Optional[int]
type: str = "new_file_message"

def to_json(self) -> str:
Expand All @@ -92,6 +97,7 @@ def to_json(self) -> str:
"file": self.file,
"sender": self.sender,
"receiver": self.receiver,
"reply_to": self.reply_to,
"sender_username": self.sender_username,
})

Expand Down
19 changes: 19 additions & 0 deletions django_private_chat2/migrations/0003_messagemodel_reply_to.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Generated by Django 4.0 on 2022-01-02 17:33

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
('django_private_chat2', '0002_auto_20210329_2217'),
]

operations = [
migrations.AddField(
model_name='messagemodel',
name='reply_to',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='replies', to='django_private_chat2.messagemodel', verbose_name='Reply to'),
),
]
1 change: 1 addition & 0 deletions django_private_chat2/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ class MessageModel(TimeStampedModel, SoftDeletableModel):
verbose_name=_("File"), blank=True, null=True)

read = models.BooleanField(verbose_name=_("Read"), default=False)
reply_to = models.ForeignKey('self', on_delete=models.CASCADE, verbose_name=_("Reply to"), related_name='replies', db_index=True, null=True, blank=True)
all_objects = models.Manager()

@staticmethod
Expand Down
6 changes: 3 additions & 3 deletions django_private_chat2/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,10 @@ def serialize_file_model(m: UploadedFile) -> Dict[str, str]:
'size': m.file.size, 'name': os.path.basename(m.file.name)}


def serialize_message_model(m: MessageModel, user_id):
def serialize_message_model(m: MessageModel, user_id) -> Dict[str, bool | Dict[str, str] | None | int | str]:
sender_pk = m.sender.pk
is_out = sender_pk == user_id
# TODO: add forwards
# TODO: add replies
obj = {
"id": m.id,
"text": m.text,
Expand All @@ -23,7 +22,8 @@ def serialize_message_model(m: MessageModel, user_id):
"sender": str(sender_pk),
"recipient": str(m.recipient.pk),
"out": is_out,
"sender_username": m.sender.get_username()
"sender_username": m.sender.get_username(),
"reply_to": m.reply_to.id if m.reply_to else None
}
return obj

Expand Down
2 changes: 1 addition & 1 deletion example/frontend/.config/dotnet-tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"isRoot": true,
"tools": {
"fable": {
"version": "3.1.7",
"version": "3.6.3",
"commands": [
"fable"
]
Expand Down
Loading