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

Add conversations API #8832

Merged
merged 9 commits into from
Oct 7, 2018
Merged

Add conversations API #8832

merged 9 commits into from
Oct 7, 2018

Conversation

Gargron
Copy link
Member

@Gargron Gargron commented Sep 29, 2018

Fix #3255

Replacement for computationally-heavy and unsatisfying direct timelines API. The new API would list DM conversations, rather than individual DMs, which allows for UX that people are more used to.

  • REST API: GET /api/v1/conversations
  • Streaming API: New event conversation in the direct stream

  • Data structures
  • REST API
  • Streaming API
  • Tests
  • Basic support in Web UI
  • Polished design in Web UI
  • Keyboard navigation in Web UI

@Gargron Gargron added api REST API, Streaming API, Web Push API work in progress Not to be merged, currently being worked on labels Sep 29, 2018
@Gargron Gargron force-pushed the feature-conversations-api branch 3 times, most recently from 51fe688 to 946008c Compare October 2, 2018 23:47
@ClearlyClaire ClearlyClaire self-requested a review October 3, 2018 11:06
Copy link
Contributor

@ClearlyClaire ClearlyClaire left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, I'm not completely sure what conversations are supposed to be here. I gather that they are threads of direct messages and are associated with a given number of participants. Multiple conversations with the same set of participants seem possible. Is that on purpose? It seems like a toot can be part of multiple conversations (through adding/removing participants). Is that also on purpose?

Finally, I'm very confused at how participants are computed, there might be a bug, there? To me, participants would be the author of the toot + all mentioned users. It seems that in this case, it's all mentioned users and not the author of the toot? I think there might be a bug there.

(Haven't reviewed front-end or streaming API changes)

end

def add_status(recipient, status)
conversation = find_or_initialize_by(account: recipient, conversation_id: status.conversation_id, participant_account_ids: participants_from_status(status))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's somewhat unlikely to happen, but I'm worried about race conditions here.

private

def participants_from_status(status)
(status.mentions.pluck(:account_id) - [status.account_id]).sort
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The toot's author isn't part of the conversation… ?

while (last_status_id = conversation.status_ids.pop)
last_status = Status.find(last_status_id)
break if last_status
end
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I find this loop slightly confusing. Maybe a comment should be added to make clear that it only removes “orphaned” items? Also, why not remove “orphaned” items regardless of whether the last toot was removed?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure we even need a loop here? If you're just removing a single status at a time, there's no reason for any other status to be in conversation.status_ids but not actually exist in the database, right?

This whole thing would be less confusing with a normal rails has_many relationship

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because it only removes toots from the array if the removed toot is the last one. This is to track the last status, which should always be the visible one.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed, we don't update the record if a deleted status is not the last status, therefore the array could contain IDs of statuses that no longer exist.

end

def add_status(recipient, status)
conversation = find_or_initialize_by(account: recipient, conversation_id: status.conversation_id, participant_account_ids: participants_from_status(recipient, status))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Re-stating my concern about race conditions as github marked my comment as obsolete)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure I understand. Shouldn't find_or_initialize_by be atomic?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

find_or_createis not atomic: https://apidock.com/rails/v4.0.2/ActiveRecord/Relation/find_or_create_by
But here, it's even worse, we are using find_or_initialize_by, which means the database record is only created when we call save a few lines further.
There is a possibility multiple incoming toots for a same new conversations would be processed in parallel. This could for instance occur if sidekiq is down while someone sends us a string of DMs, which could then be processed at the same time when sidekiq goes up, resulting in two conversations for the same threads.

@Gargron Gargron removed the work in progress Not to be merged, currently being worked on label Oct 3, 2018
# last_status_id :bigint(8)
#

class ConversationAccount < ApplicationRecord
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think AccountConversations makes at least a little bit more sense here.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, that makes perfect sense, and I hate how much I'm gonna need to search and replace here oof

Copy link
Member

@nightpool nightpool left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incomplete, sorry :(

end

def pagination_params(core_params)
params.slice(:limit).permit(:limit).merge(core_params)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we need both slice and permit here? Just permit should be nearly identical

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Slice is required to avoid warnings in the log about unpermitted parameters being passed.

set_pagination_headers(next_path, prev_path)
end

def next_path
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is all this pagination boilerplate really necessary? can't it be abstracted into a module?

this._selectChild(elementIndex);
}

_selectChild (index) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

all the other methods in this class are shorthand lambda types, so this one probably should be as well? Or vice-versa?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because this method is called from other methods rather being an event handler directly, there are no issues with this binding that the other syntax is meant to solve.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, the lambda form is probably better but we should be consistent throughout the file (and ideally throughout the project), lexical vs function target this is a hard distinction to remember in the moment.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Event handlers use =, everything else uses the normal class method form.

def push_to_streaming_api
return unless subscribed_to_timeline?

if destroyed?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unless destroyed?, remove the comment

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a stub. There probably should be a delete event for these. The problem is event naming. It would have to be called something ugly like "conversation_delete"

@@ -69,7 +69,7 @@ const expandNormalizedNotifications = (state, notifications, next) => {
}

if (!next) {
mutable.set('hasMore', true);
mutable.set('hasMore', false);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unrelated?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a bug I found when copying the code to conversations reducer. I think it's relevant.

belongs_to :last_status, class_name: 'Status'

def participant_account_ids=(arr)
self[:participant_account_ids] = arr.sort
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does find_or_initialize_by bypass this?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, it doesn't

end

def add_status(recipient, status)
conversation = find_or_initialize_by(account: recipient, conversation_id: status.conversation_id, participant_account_ids: participants_from_status(recipient, status))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure I understand. Shouldn't find_or_initialize_by be atomic?

while (last_status_id = conversation.status_ids.pop)
last_status = Status.find(last_status_id)
break if last_status
end
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure we even need a loop here? If you're just removing a single status at a time, there's no reason for any other status to be in conversation.status_ids but not actually exist in the database, right?

This whole thing would be less confusing with a normal rails has_many relationship

# account_id :bigint(8)
# conversation_id :bigint(8)
# participant_account_ids :bigint(8) default([]), not null, is an Array
# status_ids :bigint(8) default([]), not null, is an Array
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure I understand why we're using postgres arrays here instead of two has_many tables. It makes the model code somewhat brittle and hard to understand

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure about participant_account_ids, but afaik, status_ids makes sense because it's ordered and only used to track the latest status in a thread more efficiently when deleting statuses.
(Although it probably doesn't handle toots received out-of-order that well, hm)

# updated_at :datetime not null
# severity :integer default("silence")
# reject_media :boolean default(FALSE), not null
# reject_reports :boolean default(FALSE), not null
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unrelated to this pull?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ClearlyClaire
Copy link
Contributor

Do conversations with blocked users disappear from the list? It doesn't seem so by having a quick look at the code.

@ClearlyClaire
Copy link
Contributor

I'm still worried about possible race conditions, but otherwise, this looks fine.
I will probably try that next week on my instance.
What about existing conversations (prior to the migration), though?

@rhaamo
Copy link

rhaamo commented Oct 7, 2018

That's really great !

I only have one concern about the fact that blocking or muting an user would remove the actual conversations : in a context of harrassment it could be useful to not delete thoses conversations but rather kept them, but still having the possibility to delete a whole conversation afterward. (you block the user or mute and then deal with reporting with the actual conversation accessible..)

Also if it stays like that (deleting conv on mute/block) this should be indicated in various block/mute(/mute instance maybe too ?) popups !

@ClearlyClaire
Copy link
Contributor

ClearlyClaire commented Oct 7, 2018

Hm, yeah, having an API to delete conversations instead of deleting them on block/mute would be nice (sorry about my conflicting earlier proposal). Shouldn't be hard to do either, although I'm not sure how the UI would look like.

EDIT: It would also solve the issue about deleting conversations where some participant has been blocked and some haven't been, since it's not clear what to do in that situation.

@nightpool
Copy link
Member

nightpool commented Oct 7, 2018 via email

@Gargron
Copy link
Member Author

Gargron commented Oct 7, 2018

I only have one concern about the fact that blocking or muting an user would remove the actual conversations : in a context of harrassment it could be useful to not delete thoses conversations but rather kept them, but still having the possibility to delete a whole conversation afterward. (you block the user or mute and then deal with reporting with the actual conversation accessible..)

Block & mute-with-notifications has always deleted notifications from the user. I think it would be strange if the conversations were not cleaned up the same way. The latest behaviour in the PR is consistent with everything else, I believe.

we could add the rails lock_version column to avoid race conditions

There might be no need, the docs say lock_version only works within a single Ruby process anyway so if anything we would need SELECT ... FOR UPDATE

@Gargron
Copy link
Member Author

Gargron commented Oct 7, 2018

EDIT: It would also solve the issue about deleting conversations where some participant has been blocked and some haven't been, since it's not clear what to do in that situation.

The block behaviour hides posts where a blocked person is even just mentioned, so that seems already decided.

@nightpool
Copy link
Member

nightpool commented Oct 7, 2018 via email

@Gargron
Copy link
Member Author

Gargron commented Oct 7, 2018

This locking mechanism will function inside a single Ruby process. To make it work across all web requests, the recommended approach is to add lock_version as a hidden field to your form.

@Gargron
Copy link
Member Author

Gargron commented Oct 7, 2018

Anyway, what I was saying, DM conversations usually don't have so many participants that an update happens within the same second to the point a race condition would actually occur. But if it does, I am ready to add a locking mechanism in the future.

I just want to merge this now

@ClearlyClaire
Copy link
Contributor

As I said, a likely possibility is getting two successive DMs from the same person. Depending on the load, they could very well be processed concurrently.

@@ -525,9 +526,11 @@ const startWorker = (workerId) => {
ws.isAlive = true;
});

let channel;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure I understand this change. Why make it mutable? We never fall through any of the switch cases, right?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few of the switch cases needed to reuse the same assembled string (channel) in two places at once, but a switch clause is not a separate scope, so you can't redefine a const there. Therefore, it's easiest to define the channel up there.

@nightpool
Copy link
Member

LGTM!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
api REST API, Streaming API, Web Push API
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants