chats 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +349 -0
- data/README.md +91 -8
- data/app/assets/stylesheets/chats.css +114 -0
- data/app/controllers/chats/application_controller.rb +45 -4
- data/app/controllers/chats/conversations_controller.rb +305 -63
- data/app/helpers/chats/engine_helper.rb +27 -4
- data/app/javascript/chats/dock_controller.js +54 -4
- data/app/javascript/chats/recovery.js +539 -0
- data/app/javascript/chats/refresh_inbox_controller.js +55 -65
- data/app/javascript/chats/split_controller.js +97 -0
- data/app/javascript/chats/thread_controller.js +245 -54
- data/app/javascript/chats/unread_badge_controller.js +24 -0
- data/app/views/chats/conversations/_conversation_pane.html.erb +42 -0
- data/app/views/chats/conversations/_conversation_row.html.erb +20 -4
- data/app/views/chats/conversations/_empty_pane.html.erb +23 -0
- data/app/views/chats/conversations/_group.html.erb +20 -4
- data/app/views/chats/conversations/_history.html.erb +54 -0
- data/app/views/chats/conversations/_thread.html.erb +29 -54
- data/app/views/chats/conversations/_thread_header.html.erb +83 -0
- data/app/views/chats/conversations/badge.turbo_stream.erb +6 -0
- data/app/views/chats/conversations/index.html.erb +91 -15
- data/app/views/chats/conversations/pane.html.erb +12 -0
- data/app/views/chats/conversations/refresh.turbo_stream.erb +24 -4
- data/app/views/chats/conversations/show.html.erb +10 -65
- data/app/views/chats/dock/_dock.html.erb +14 -5
- data/app/views/chats/messages/_message.html.erb +8 -6
- data/chats-0.3.2.gem +0 -0
- data/chats-0.4.0.gem +0 -0
- data/chats-0.4.1.gem +0 -0
- data/chats-0.4.2.gem +0 -0
- data/chats-0.5.0.gem +0 -0
- data/chats-0.5.1.gem +0 -0
- data/config/importmap.rb +7 -0
- data/config/locales/en.yml +1 -0
- data/config/locales/es.yml +1 -0
- data/config/routes.rb +3 -0
- data/docs/screenshots/split-inbox-empty.png +0 -0
- data/docs/screenshots/split-inbox-phone-list.png +0 -0
- data/docs/screenshots/split-inbox-phone-thread.png +0 -0
- data/docs/screenshots/split-inbox.png +0 -0
- data/lib/chats/broadcasts.rb +102 -7
- data/lib/chats/configuration.rb +23 -0
- data/lib/chats/inbox_refresh_job.rb +14 -0
- data/lib/chats/models/conversation.rb +38 -0
- data/lib/chats/models/message.rb +98 -9
- data/lib/chats/models/reaction.rb +13 -0
- data/lib/chats/version.rb +1 -1
- data/lib/generators/chats/templates/initializer.rb +9 -0
- metadata +21 -1
|
@@ -32,6 +32,13 @@
|
|
|
32
32
|
--chats-bubble-text: #111827;
|
|
33
33
|
--chats-radius: 1.1rem;
|
|
34
34
|
--chats-max-width: 42rem;
|
|
35
|
+
/* The conversation list beside an open conversation (two panes). */
|
|
36
|
+
--chats-list-width: 320px;
|
|
37
|
+
/* --chats-split-height, how tall the two-pane page is, is NOT declared
|
|
38
|
+
here: the rules read it with the whole screen (100dvh) as the fallback,
|
|
39
|
+
so a host's own declaration wins wherever it is, even on a :root rule
|
|
40
|
+
loaded before this file (the bundled views link it in the body).
|
|
41
|
+
Under a host header: calc(100dvh - <the header's height>). */
|
|
35
42
|
|
|
36
43
|
/* Safe areas, re-pointable by a host with its own source of truth. */
|
|
37
44
|
--chats-safe-top: env(safe-area-inset-top, 0px);
|
|
@@ -133,6 +140,9 @@
|
|
|
133
140
|
flex-shrink: 0;
|
|
134
141
|
}
|
|
135
142
|
.chats-badge--floating[hidden] { display: none; }
|
|
143
|
+
/* chats_unread_badge's wrapper (its catch-up controller): no box of its own,
|
|
144
|
+
so the badge sits in the host's layout exactly as if it weren't there. */
|
|
145
|
+
.chats-unread-badge-live { display: contents; }
|
|
136
146
|
|
|
137
147
|
/* --- Empty states ------------------------------------------------------------ */
|
|
138
148
|
|
|
@@ -1004,6 +1014,110 @@ turbo-frame[id^="chats_page_"] {
|
|
|
1004
1014
|
}
|
|
1005
1015
|
|
|
1006
1016
|
|
|
1017
|
+
/* ---------------------------------------------------------------------------
|
|
1018
|
+
0.6.0 — two panes (config.inbox_layout = :split)
|
|
1019
|
+
---------------------------------------------------------------------------
|
|
1020
|
+
|
|
1021
|
+
The messaging page as LinkedIn has it: from 700px wide (the dock's phone
|
|
1022
|
+
rule, and chats--split's), the list on the left and the open conversation
|
|
1023
|
+
on the right, both as tall as the screen, each scrolling on its own. Below
|
|
1024
|
+
that, one column, exactly as the separate pages: /messages is the list,
|
|
1025
|
+
/messages/:id the thread. Every request renders both panes; this media
|
|
1026
|
+
query alone picks, so it holds before JavaScript runs and without it.
|
|
1027
|
+
|
|
1028
|
+
.chats-split the page (.chats.chats-inbox too)
|
|
1029
|
+
.chats-split__list the list's <nav>
|
|
1030
|
+
.chats-split__conversation the `chats_conversation` frame
|
|
1031
|
+
.chats-split__pane a conversation in it (section)
|
|
1032
|
+
.chats-split__thread its permanent wrapper, around .chats-thread--pane
|
|
1033
|
+
.chats-split__empty no conversation open (--inbox: no conversations at all)
|
|
1034
|
+
|
|
1035
|
+
The page is as wide as the list plus a thread page (--chats-max-width),
|
|
1036
|
+
so the conversation reads at the width it always had, and as tall as
|
|
1037
|
+
--chats-split-height (the screen; a host with its own header above the
|
|
1038
|
+
page sets what is left: calc(100dvh - 4rem)). */
|
|
1039
|
+
.chats-split {
|
|
1040
|
+
max-width: calc(var(--chats-list-width) + var(--chats-max-width));
|
|
1041
|
+
/* The list alone (a phone): at least what is left of the screen, never
|
|
1042
|
+
the shell's whole 100dvh, which would run past it under a host header. */
|
|
1043
|
+
min-height: var(--chats-split-height, 100dvh);
|
|
1044
|
+
}
|
|
1045
|
+
.chats-split__list { display: flex; flex-direction: column; min-width: 0; }
|
|
1046
|
+
.chats-split__conversation {
|
|
1047
|
+
display: flex;
|
|
1048
|
+
flex-direction: column;
|
|
1049
|
+
min-width: 0;
|
|
1050
|
+
min-height: 0;
|
|
1051
|
+
}
|
|
1052
|
+
.chats-split__pane,
|
|
1053
|
+
.chats-split__thread {
|
|
1054
|
+
flex: 1;
|
|
1055
|
+
min-width: 0;
|
|
1056
|
+
min-height: 0;
|
|
1057
|
+
display: flex;
|
|
1058
|
+
flex-direction: column;
|
|
1059
|
+
}
|
|
1060
|
+
/* The thread page's body, inside the pane: no shell of its own (the page is
|
|
1061
|
+
the .chats shell), and the pane's height, not the screen's. */
|
|
1062
|
+
.chats-thread--pane {
|
|
1063
|
+
flex: 1;
|
|
1064
|
+
min-height: 0;
|
|
1065
|
+
height: auto;
|
|
1066
|
+
display: flex;
|
|
1067
|
+
flex-direction: column;
|
|
1068
|
+
overflow: hidden;
|
|
1069
|
+
}
|
|
1070
|
+
.chats-split__empty { margin: auto; }
|
|
1071
|
+
.chats-row--current .chats-row__link {
|
|
1072
|
+
background: var(--chats-surface);
|
|
1073
|
+
box-shadow: inset 3px 0 0 var(--chats-accent);
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
@media (min-width: 700px) {
|
|
1077
|
+
.chats-split {
|
|
1078
|
+
box-sizing: border-box;
|
|
1079
|
+
flex-direction: row;
|
|
1080
|
+
height: var(--chats-split-height, 100dvh);
|
|
1081
|
+
min-height: 0;
|
|
1082
|
+
overflow: hidden;
|
|
1083
|
+
/* Its edges, where a page wider than it shows the host's background. */
|
|
1084
|
+
border-inline: 1px solid var(--chats-border);
|
|
1085
|
+
}
|
|
1086
|
+
.chats-split__list {
|
|
1087
|
+
box-sizing: border-box;
|
|
1088
|
+
flex: 0 0 var(--chats-list-width);
|
|
1089
|
+
width: var(--chats-list-width);
|
|
1090
|
+
border-right: 1px solid var(--chats-border);
|
|
1091
|
+
}
|
|
1092
|
+
/* The rows scroll; the title and the search stay. */
|
|
1093
|
+
.chats-split__list > turbo-frame {
|
|
1094
|
+
display: block;
|
|
1095
|
+
flex: 1;
|
|
1096
|
+
min-height: 0;
|
|
1097
|
+
overflow-y: auto;
|
|
1098
|
+
overscroll-behavior-y: contain;
|
|
1099
|
+
}
|
|
1100
|
+
.chats-split__conversation { flex: 1 1 0; }
|
|
1101
|
+
/* The list is right there: no way back to it. */
|
|
1102
|
+
.chats-split .chats-thread__back { display: none; }
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
@media (max-width: 699.98px) {
|
|
1106
|
+
/* A conversation open: the page is the thread, as tall as the screen. */
|
|
1107
|
+
.chats-split:has(.chats-split__pane) {
|
|
1108
|
+
height: var(--chats-split-height, 100dvh);
|
|
1109
|
+
min-height: 0;
|
|
1110
|
+
overflow: hidden;
|
|
1111
|
+
}
|
|
1112
|
+
.chats-split:has(.chats-split__pane) > .chats-split__list { display: none; }
|
|
1113
|
+
.chats-split:has(.chats-split__pane) > .chats-split__conversation { flex: 1; }
|
|
1114
|
+
/* None: the page is the list. The empty pane shows under it only to say
|
|
1115
|
+
there are no conversations at all (the inbox's empty state lives there). */
|
|
1116
|
+
.chats-split:not(:has(.chats-split__pane, .chats-split__empty--inbox)) > .chats-split__conversation {
|
|
1117
|
+
display: none;
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1007
1121
|
/* ---------------------------------------------------------------------------
|
|
1008
1122
|
0.5.0 — the messaging dock (chats_dock)
|
|
1009
1123
|
---------------------------------------------------------------------------
|
|
@@ -55,11 +55,52 @@ module Chats
|
|
|
55
55
|
Chats.config.layout
|
|
56
56
|
end
|
|
57
57
|
|
|
58
|
-
# Conversations are ALWAYS resolved through
|
|
59
|
-
#
|
|
60
|
-
#
|
|
58
|
+
# Conversations are ALWAYS resolved through relations that only hold
|
|
59
|
+
# what this viewer may open (conversation_lookup_scopes): not a
|
|
60
|
+
# participant, left, or a blocked counterpart all come back as a plain
|
|
61
|
+
# 404, the same 404 as an id that doesn't exist — existence is never
|
|
62
|
+
# leaked to outsiders. Every engine endpoint (the thread, a dock window,
|
|
63
|
+
# sending, editing, reacting, reading) looks a conversation up here.
|
|
64
|
+
#
|
|
65
|
+
# The first scope that has it wins; later scopes are asked only on a
|
|
66
|
+
# miss. Errors a scope raises are not rescued: a broken product scope
|
|
67
|
+
# must fail loudly, never turn into a quiet 404.
|
|
61
68
|
def find_conversation(id = params[:id])
|
|
62
|
-
|
|
69
|
+
conversation_lookup_scopes.each do |scope|
|
|
70
|
+
conversation = scope.find_by(id: id)
|
|
71
|
+
return conversation if conversation
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
raise ActiveRecord::RecordNotFound.new(
|
|
75
|
+
"Couldn't find Chats::Conversation with 'id'=#{id}", Chats::Conversation.name, "id", id
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
protected
|
|
80
|
+
|
|
81
|
+
# Where find_conversation looks, in order: by default the viewer's own
|
|
82
|
+
# inbox relation (`Messager#chats`: their active seats, blocks and
|
|
83
|
+
# `config.inbox_scope` applied).
|
|
84
|
+
#
|
|
85
|
+
# THE extension point for a conversation that must open although the
|
|
86
|
+
# inbox hides it. A product gem built on chats (support_desk: a
|
|
87
|
+
# requester's own ticket, which the host's inbox_scope keeps out of the
|
|
88
|
+
# inbox) prepends a module that appends its own relation:
|
|
89
|
+
#
|
|
90
|
+
# module SupportDesk::ConversationLookup
|
|
91
|
+
# protected
|
|
92
|
+
#
|
|
93
|
+
# def conversation_lookup_scopes
|
|
94
|
+
# super + [SupportDesk::Ticket.conversations_of(chats_current_messager)]
|
|
95
|
+
# end
|
|
96
|
+
# end
|
|
97
|
+
# Chats::ApplicationController.prepend(SupportDesk::ConversationLookup)
|
|
98
|
+
#
|
|
99
|
+
# Each scope must hold only conversations this viewer may open: the
|
|
100
|
+
# union of the scopes IS the authorization. Nothing the scopes don't
|
|
101
|
+
# name becomes reachable.
|
|
102
|
+
def conversation_lookup_scopes
|
|
103
|
+
[chats_current_messager.chats]
|
|
63
104
|
end
|
|
64
105
|
end
|
|
65
106
|
end
|
|
@@ -7,9 +7,19 @@ module Chats
|
|
|
7
7
|
class ConversationsController < ApplicationController
|
|
8
8
|
# The most rows the dock's recent list shows, whatever `recent:` asks.
|
|
9
9
|
DOCK_RECENT_LIMIT = 50
|
|
10
|
+
# How far before its list of what exists a catch-up stops naming
|
|
11
|
+
# deletions (see #present_messages).
|
|
12
|
+
PRESENT_MARGIN = 10.seconds
|
|
13
|
+
# The most ids a catch-up cursor may name, and the longest id (a UUID is
|
|
14
|
+
# 36): a client past either gets its history re-rendered instead.
|
|
15
|
+
CURSOR_IDS_LIMIT = 200
|
|
16
|
+
CURSOR_ID_LENGTH = 64
|
|
17
|
+
# The two-pane messaging page's conversation frame (index.html.erb): a
|
|
18
|
+
# request from it is answered with the pane alone (pane.html.erb).
|
|
19
|
+
PANE_FRAME = "chats_conversation"
|
|
10
20
|
|
|
11
21
|
before_action :set_conversation, only: %i[show read typing leave mute unmute refresh]
|
|
12
|
-
helper_method :chats_counterpart
|
|
22
|
+
helper_method :chats_counterpart, :chats_two_panes?, :chats_inbox_filter
|
|
13
23
|
|
|
14
24
|
# The inbox. Everything is preloaded/batched so rendering N rows costs a
|
|
15
25
|
# constant number of queries (conversations + last messages + participants
|
|
@@ -22,14 +32,7 @@ module Chats
|
|
|
22
32
|
# is the dock's own list.
|
|
23
33
|
return dock if turbo_frame_request_id == "chats_dock_list"
|
|
24
34
|
|
|
25
|
-
|
|
26
|
-
@rows = @inbox.rows
|
|
27
|
-
@unread_counts = @inbox.unread_counts
|
|
28
|
-
# Back-compat for inboxes ejected under 0.1.x, which loop over
|
|
29
|
-
# @conversations: they keep rendering the flat list (no stacking, i.e.
|
|
30
|
-
# exactly what they rendered before). Re-eject or delete your copy to
|
|
31
|
-
# get the stacked rows.
|
|
32
|
-
@conversations = @inbox.conversations
|
|
35
|
+
load_inbox
|
|
33
36
|
end
|
|
34
37
|
|
|
35
38
|
# The messaging dock's recent list (`chats_dock`): the first rows of the
|
|
@@ -44,6 +47,15 @@ module Chats
|
|
|
44
47
|
render :dock, layout: false
|
|
45
48
|
end
|
|
46
49
|
|
|
50
|
+
# The viewer's unread badges as a Turbo Stream: the nav badge
|
|
51
|
+
# (`chats_unread_badge`) and every badge in the dock, replaced with the
|
|
52
|
+
# same count the badge streams send. What a page asks for when it may
|
|
53
|
+
# have missed them (chats/recovery: a reconnect, a return to a hidden
|
|
54
|
+
# page, a restored page). Reads nothing, writes nothing.
|
|
55
|
+
def badge
|
|
56
|
+
render :badge, formats: :turbo_stream, locals: { count: chats_current_messager.unread_chats_count }
|
|
57
|
+
end
|
|
58
|
+
|
|
47
59
|
# The thread. Renders the LATEST page of messages; older pages stream in
|
|
48
60
|
# through a lazy Turbo Frame chain (keyset-paginated — see
|
|
49
61
|
# Message.before_message and _messages_page.html.erb).
|
|
@@ -51,19 +63,19 @@ module Chats
|
|
|
51
63
|
# `?window=1` from a Turbo frame is the same thread as a floating dock
|
|
52
64
|
# window: the window template (its compact header around the same
|
|
53
65
|
# thread body), no layout.
|
|
66
|
+
#
|
|
67
|
+
# With two panes (`config.inbox_layout = :split`, the default) the
|
|
68
|
+
# thread is the conversation pane of the messaging page: the whole page
|
|
69
|
+
# (the inbox's template, with the pane open), or, asked from that page's
|
|
70
|
+
# own conversation frame (a row clicked on a wide screen), the pane
|
|
71
|
+
# alone. `:stacked` renders the thread page.
|
|
54
72
|
def show
|
|
55
73
|
@participant = @conversation.participant_for(chats_current_messager)
|
|
56
74
|
|
|
57
75
|
anchor = params[:before].present? ? @conversation.messages.find_by(id: params[:before]) : nil
|
|
58
|
-
scope =
|
|
59
|
-
scope = scope.with_attached_files if scope.respond_to?(:with_attached_files)
|
|
76
|
+
scope = rendered_messages
|
|
60
77
|
scope = scope.before_message(anchor) if anchor
|
|
61
|
-
|
|
62
|
-
# Fetch newest-first + reverse so "the last N messages" render in
|
|
63
|
-
# chronological order. One extra record peeks whether older pages exist.
|
|
64
|
-
page = scope.recent_first.limit(Chats.config.messages_per_page + 1).to_a
|
|
65
|
-
@more_messages = page.size > Chats.config.messages_per_page
|
|
66
|
-
@messages = page.first(Chats.config.messages_per_page).reverse
|
|
78
|
+
@messages, @more_messages = latest_page_of(scope)
|
|
67
79
|
|
|
68
80
|
if anchor
|
|
69
81
|
# Older-page request from the pagination frame: render just the page.
|
|
@@ -71,14 +83,8 @@ module Chats
|
|
|
71
83
|
locals: { conversation: @conversation, messages: @messages, more: @more_messages }
|
|
72
84
|
else
|
|
73
85
|
# The «new messages» divider: computed BEFORE read! advances the
|
|
74
|
-
# horizon (after it, nothing is unread anymore).
|
|
75
|
-
|
|
76
|
-
# deeper than one page it pins to the top of the page instead —
|
|
77
|
-
# the scroll-up frame chain holds the rest.
|
|
78
|
-
if @participant&.unread?
|
|
79
|
-
@first_unread_id = @participant.unread_messages.where(id: @messages.map(&:id)).oldest_first.pick(:id) ||
|
|
80
|
-
@messages.first&.id
|
|
81
|
-
end
|
|
86
|
+
# horizon (after it, nothing is unread anymore).
|
|
87
|
+
@first_unread_id = first_unread_id(@participant, @messages)
|
|
82
88
|
|
|
83
89
|
if dock_window?
|
|
84
90
|
# A window does NOT read on render: it may be restored minimized
|
|
@@ -86,51 +92,64 @@ module Chats
|
|
|
86
92
|
# swallow the unread badge. The thread controller posts #read once
|
|
87
93
|
# the window is actually on screen (`read-pending`).
|
|
88
94
|
render :window, layout: false
|
|
89
|
-
|
|
95
|
+
else
|
|
90
96
|
# Opening the thread reads it. (Live appends while the thread
|
|
91
97
|
# stays open are read via the thread controller's POST to #read.)
|
|
92
|
-
|
|
98
|
+
# A render the server doesn't read is marked `read-pending`: the
|
|
99
|
+
# thread controller reads it once it is on screen.
|
|
100
|
+
@read_pending = !reads_on_render?
|
|
101
|
+
@participant&.read! unless @read_pending
|
|
102
|
+
render_split_page if chats_two_panes?
|
|
93
103
|
end
|
|
94
104
|
end
|
|
95
105
|
end
|
|
96
106
|
|
|
97
|
-
# Stale-thread catch-up
|
|
98
|
-
#
|
|
99
|
-
#
|
|
100
|
-
#
|
|
101
|
-
#
|
|
102
|
-
#
|
|
103
|
-
#
|
|
104
|
-
#
|
|
105
|
-
#
|
|
107
|
+
# Stale-thread catch-up, on the client's cursors (see Message's catch-up
|
|
108
|
+
# scopes): `after=` µs and `after_ids=`, its newest creation and the ids
|
|
109
|
+
# it has at that moment (0 and none for an empty thread); `changed=` µs
|
|
110
|
+
# and `changed_ids=`, its newest change; `from=` µs, its oldest bubble's
|
|
111
|
+
# creation. Bubbles ejected before they carried these send `since=` ms
|
|
112
|
+
# alone, and are answered as they always were. The thread
|
|
113
|
+
# controller calls this whenever chats/recovery says the stream may have
|
|
114
|
+
# missed something: a reconnect, a return after 10 seconds hidden, the
|
|
115
|
+
# network back, a restored page. Mobile WebViews suspend WebSockets
|
|
116
|
+
# aggressively, so without this a backgrounded chat silently loses
|
|
117
|
+
# messages until a manual reload. Pattern from Basecamp's Campfire
|
|
118
|
+
# (Rooms::RefreshesController): https://github.com/basecamp/once-campfire
|
|
119
|
+
#
|
|
120
|
+
# The answer touches the history only, never the composer or its draft:
|
|
121
|
+
# messages created since are appended, ones changed since (edits,
|
|
122
|
+
# tombstones, reactions) replaced, the read state replaced, and — under
|
|
123
|
+
# `deletion: :hard` — the bubbles that no longer exist removed. Reads
|
|
124
|
+
# nothing: catching up is not reading (the controller posts #read once
|
|
125
|
+
# the viewer can see the new messages).
|
|
126
|
+
#
|
|
127
|
+
# `render turbo_stream:`, NOT `render html: … content_type:` — the
|
|
128
|
+
# latter forces text/html and silently ignores the content type, so the
|
|
129
|
+
# body says <turbo-stream> while the response says it isn't one.
|
|
106
130
|
def refresh
|
|
107
|
-
head :no_content and return if params[:since].blank?
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
#
|
|
119
|
-
#
|
|
120
|
-
#
|
|
121
|
-
#
|
|
122
|
-
#
|
|
123
|
-
#
|
|
124
|
-
#
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
render turbo_stream: turbo_stream.refresh(request_id: nil)
|
|
130
|
-
return
|
|
131
|
-
end
|
|
132
|
-
|
|
133
|
-
@updated_messages = scope.updated_since(since)
|
|
131
|
+
head :no_content and return if params[:changed].blank? && params[:since].blank?
|
|
132
|
+
|
|
133
|
+
# A client whose cursors name more than the limit (a busy thread whose
|
|
134
|
+
# live bubbles piled up since it last caught up, or a forged request)
|
|
135
|
+
# is answered with what is cheap at any size: the latest page.
|
|
136
|
+
return render_from_scratch if cursors_over_limit?
|
|
137
|
+
|
|
138
|
+
scope = rendered_messages
|
|
139
|
+
new_messages, @updated_messages = catch_up_on(scope)
|
|
140
|
+
@new_messages = new_messages.oldest_first.limit(Chats.config.messages_per_page + 1).to_a
|
|
141
|
+
|
|
142
|
+
# More than a page behind: splicing an arbitrary backlog through
|
|
143
|
+
# surgical appends would leave a hole in the scroll-up chain. Re-render
|
|
144
|
+
# the history at the latest page instead, exactly as opening the thread
|
|
145
|
+
# does, frame chain included. A thread view ejected before the history
|
|
146
|
+
# had its id (no `history=1`) gets the page refresh it always got;
|
|
147
|
+
# `request_id: nil`, because Turbo skips a refresh tagged with a request
|
|
148
|
+
# of its own, and this one answers the client's own catch-up.
|
|
149
|
+
return render_from_scratch if @new_messages.size > Chats.config.messages_per_page
|
|
150
|
+
|
|
151
|
+
@caught_up = caught_up_cursors if params[:changed].present?
|
|
152
|
+
@present = present_messages if Chats.config.deletion == :hard && params[:from].present?
|
|
134
153
|
render "chats/conversations/refresh", formats: :turbo_stream
|
|
135
154
|
end
|
|
136
155
|
|
|
@@ -213,6 +232,229 @@ module Chats
|
|
|
213
232
|
@conversation = find_conversation
|
|
214
233
|
end
|
|
215
234
|
|
|
235
|
+
# The inbox's rows, for the inbox page and for the two-pane page.
|
|
236
|
+
def load_inbox
|
|
237
|
+
@inbox = Chats::Inbox.for(chats_current_messager, query: params[:q], with: inbox_filter)
|
|
238
|
+
@rows = @inbox.rows
|
|
239
|
+
@unread_counts = @inbox.unread_counts
|
|
240
|
+
# Back-compat for inboxes ejected under 0.1.x, which loop over
|
|
241
|
+
# @conversations: they keep rendering the flat list (no stacking, i.e.
|
|
242
|
+
# exactly what they rendered before). Re-eject or delete your copy to
|
|
243
|
+
# get the stacked rows.
|
|
244
|
+
@conversations = @inbox.conversations
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# The open conversation on the two-pane page: the pane alone for the
|
|
248
|
+
# page's own conversation frame, else the whole page around it (any
|
|
249
|
+
# other frame of the page, the search's results among them, finds its
|
|
250
|
+
# own part in it).
|
|
251
|
+
def render_split_page
|
|
252
|
+
return render :pane, layout: false if pane_request?
|
|
253
|
+
|
|
254
|
+
load_inbox
|
|
255
|
+
render :index
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def pane_request?
|
|
259
|
+
turbo_frame_request_id == PANE_FRAME
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Whether the messaging page is two panes: `config.inbox_layout = :split`
|
|
263
|
+
# (the default), unless this app ejected its views before there were
|
|
264
|
+
# two panes. `rails g chats:views` under 0.5 copied an inbox and a
|
|
265
|
+
# thread page and no conversation pane: that app keeps its own separate
|
|
266
|
+
# pages, rendered and read exactly as before, until it ejects again (or
|
|
267
|
+
# deletes its copies). Views ejected since 0.6 carry the pane, and are
|
|
268
|
+
# two panes like the gem's own.
|
|
269
|
+
def chats_two_panes?
|
|
270
|
+
return @chats_two_panes if defined?(@chats_two_panes)
|
|
271
|
+
|
|
272
|
+
@chats_two_panes = Chats.config.split_inbox? && !views_ejected_before_two_panes?
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# The list's filter on the two-pane page: its search (`q`) or the stack
|
|
276
|
+
# it shows (`with`). A row opened in the pane carries it into its URL,
|
|
277
|
+
# /messages/:id?with=…, because that URL is what the list's live
|
|
278
|
+
# refreshes re-request: without it, the next refresh would bring back
|
|
279
|
+
# the whole inbox under a list that was filtered. The back link to the
|
|
280
|
+
# list keeps it too.
|
|
281
|
+
def chats_inbox_filter
|
|
282
|
+
{ q: params[:q], with: params[:with] }.select { |_, value| value.is_a?(String) && value.present? }
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def views_ejected_before_two_panes?
|
|
286
|
+
(own_view?("index") || own_view?("show")) && !own_view?("conversation_pane", partial: true)
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Whether the app renders its own copy of one of these views, not the gem's.
|
|
290
|
+
def own_view?(name, partial: false)
|
|
291
|
+
template = lookup_context.find_all(name, ["chats/conversations"], partial).first
|
|
292
|
+
template.present? && !template.identifier.to_s.start_with?(Chats::Engine.root.join("app/views").to_s)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Whether showing the conversation reads it, there and then.
|
|
296
|
+
#
|
|
297
|
+
# Never a prefetch (Turbo 8 fetches a link the pointer rests on, which
|
|
298
|
+
# is nobody reading). On the thread page, everything else: opening it
|
|
299
|
+
# reads it.
|
|
300
|
+
#
|
|
301
|
+
# On the two-pane page, the viewer opening it: a row clicked (the
|
|
302
|
+
# pane's frame) or the page loaded by the browser itself (a link from
|
|
303
|
+
# elsewhere, a reload, no JavaScript). NOT a Turbo visit of the page,
|
|
304
|
+
# because one of those is the inbox's own refresh: every live update of
|
|
305
|
+
# the list re-requests this page, maybe in a hidden tab, and a read
|
|
306
|
+
# there would say "Seen" to the sender while nobody looks — and, since
|
|
307
|
+
# a read refreshes the reader's inbox, refresh the page again, forever.
|
|
308
|
+
# Turbo marks each of its requests (X-Turbo-Request-Id) and a refresh
|
|
309
|
+
# looks like any other visit, so a Turbo visit renders the pane unread
|
|
310
|
+
# and its thread reads it once it's on screen. A refresh's pane never
|
|
311
|
+
# gets that far: the page keeps its permanent one, and drops the new.
|
|
312
|
+
def reads_on_render?
|
|
313
|
+
return false if prefetch?
|
|
314
|
+
return true unless chats_two_panes?
|
|
315
|
+
|
|
316
|
+
pane_request? || request.headers["X-Turbo-Request-Id"].blank?
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def time_from_ms(value)
|
|
320
|
+
Time.zone.at(0, value.to_i, :millisecond)
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def time_from_us(value)
|
|
324
|
+
Time.zone.at(0, value.to_i, :microsecond)
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# What to append and what to replace: on the two microsecond cursors,
|
|
328
|
+
# or on the one millisecond cursor of bubbles ejected before them.
|
|
329
|
+
def catch_up_on(scope)
|
|
330
|
+
if params[:changed].present?
|
|
331
|
+
created_at, created_seen = cursor(:after)
|
|
332
|
+
updated_at, updated_seen = cursor(:changed)
|
|
333
|
+
[scope.created_after(created_at, seen: created_seen),
|
|
334
|
+
scope.created_through(created_at, seen: created_seen).updated_after(updated_at, seen: updated_seen)]
|
|
335
|
+
else
|
|
336
|
+
since = time_from_ms(params[:since])
|
|
337
|
+
[scope.created_since(since), scope.updated_since(since)]
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# The moment and the ids seen with it, from `<name>=µs` and `<name>_ids=1,2`.
|
|
342
|
+
def cursor(name)
|
|
343
|
+
[time_from_us(params[name]), params[:"#{name}_ids"].to_s.split(",")]
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def cursors_over_limit?
|
|
347
|
+
return true if params[:full].present?
|
|
348
|
+
|
|
349
|
+
%i[after_ids changed_ids].any? do |name|
|
|
350
|
+
ids = params[name].to_s.split(",")
|
|
351
|
+
ids.size > CURSOR_IDS_LIMIT || ids.any? { |id| id.size > CURSOR_ID_LENGTH }
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# Where the client stands once it has rendered this answer: what it was
|
|
356
|
+
# sent is now known to be on screen, so the cursors move to it. Sent
|
|
357
|
+
# back with the answer (the chats_caught_up action), because only an
|
|
358
|
+
# answer that arrived may move them.
|
|
359
|
+
def caught_up_cursors
|
|
360
|
+
after = cursor(:after).first
|
|
361
|
+
changed_at, changed_seen = cursor(:changed)
|
|
362
|
+
sent = @new_messages + @updated_messages.to_a
|
|
363
|
+
after = [after, *@new_messages.map(&:created_at)].max
|
|
364
|
+
|
|
365
|
+
newest = sent.map(&:updated_at).max
|
|
366
|
+
changed_seen = [] if newest && newest > changed_at
|
|
367
|
+
changed_at = [changed_at, newest].compact.max
|
|
368
|
+
changed_seen |= sent.select { |message| message.updated_at == changed_at }.map { |message| message.id.to_s }
|
|
369
|
+
|
|
370
|
+
cursors_tag(after, changed_at, changed_seen)
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def cursors_tag(after, changed_at, changed_seen)
|
|
374
|
+
helpers.turbo_stream_action_tag(
|
|
375
|
+
:chats_caught_up,
|
|
376
|
+
target: helpers.dom_id(@conversation, :messages),
|
|
377
|
+
"data-after": microseconds(after), "data-changed": microseconds(changed_at),
|
|
378
|
+
"data-changed-ids": changed_seen.join(","),
|
|
379
|
+
# Which render of the thread asked: an answer for an earlier one is
|
|
380
|
+
# ignored (see the thread controller's caughtUp).
|
|
381
|
+
"data-render": params[:render].to_s.first(CURSOR_ID_LENGTH).presence
|
|
382
|
+
)
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def microseconds(time)
|
|
386
|
+
(time.to_i * 1_000_000) + time.usec
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# The history at its latest page, or, for a thread view ejected before
|
|
390
|
+
# the history had its id (no `history=1`), the page refresh it always
|
|
391
|
+
# got. `request_id: nil`: Turbo skips a refresh tagged with a request of
|
|
392
|
+
# its own, and this one answers the client's own catch-up.
|
|
393
|
+
def render_from_scratch
|
|
394
|
+
return render_latest_history if params[:history].present?
|
|
395
|
+
|
|
396
|
+
render turbo_stream: turbo_stream.refresh(request_id: nil)
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
# The conversation's messages with everything a bubble renders.
|
|
400
|
+
def rendered_messages
|
|
401
|
+
scope = @conversation.messages.includes(:sender, :reactions)
|
|
402
|
+
scope.respond_to?(:with_attached_files) ? scope.with_attached_files : scope
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
# The newest page of +scope+, oldest first, and whether older pages
|
|
406
|
+
# exist. Fetched newest-first and reversed; one extra record peeks.
|
|
407
|
+
def latest_page_of(scope)
|
|
408
|
+
page = scope.recent_first.limit(Chats.config.messages_per_page + 1).to_a
|
|
409
|
+
[page.first(Chats.config.messages_per_page).reverse, page.size > Chats.config.messages_per_page]
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
# Where the «new messages» line goes: the oldest unread bubble on the
|
|
413
|
+
# page; when the backlog runs deeper than one page, the top of the page
|
|
414
|
+
# (the scroll-up frame chain holds the rest). Nil when nothing is unread.
|
|
415
|
+
def first_unread_id(participant, messages)
|
|
416
|
+
return unless participant&.unread?
|
|
417
|
+
|
|
418
|
+
participant.unread_messages.where(id: messages.map(&:id)).oldest_first.pick(:id) || messages.first&.id
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
# The thread's history at its latest page, for a catch-up more than a
|
|
422
|
+
# page behind: the same messages, frame chain and «new messages» line
|
|
423
|
+
# the thread opens with (chats/conversations/_history).
|
|
424
|
+
#
|
|
425
|
+
# A complete render, so it carries its own cursors: the newest creation
|
|
426
|
+
# and the newest change on the page.
|
|
427
|
+
def render_latest_history
|
|
428
|
+
messages, more = latest_page_of(rendered_messages)
|
|
429
|
+
participant = @conversation.participant_for(chats_current_messager)
|
|
430
|
+
newest_change = messages.map(&:updated_at).max || Time.zone.at(0)
|
|
431
|
+
history = turbo_stream.update(
|
|
432
|
+
helpers.dom_id(@conversation, :history),
|
|
433
|
+
partial: "chats/conversations/history",
|
|
434
|
+
locals: { conversation: @conversation, messages: messages, more: more,
|
|
435
|
+
first_unread_id: first_unread_id(participant, messages),
|
|
436
|
+
counterpart: chats_counterpart, window: false }
|
|
437
|
+
)
|
|
438
|
+
cursors = cursors_tag(messages.map(&:created_at).max || Time.zone.at(0), newest_change,
|
|
439
|
+
messages.select { |message| message.updated_at == newest_change }.map { |m| m.id.to_s })
|
|
440
|
+
|
|
441
|
+
render turbo_stream: helpers.safe_join([history, cursors])
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
# Under `deletion: :hard` a deleted message leaves nothing to replace:
|
|
445
|
+
# the catch-up names every message from the client's oldest bubble on
|
|
446
|
+
# that still exists, and the client removes the bubbles not named.
|
|
447
|
+
# `until` sits PRESENT_MARGIN before the list is read: a message whose
|
|
448
|
+
# insert had not committed yet is missing from the list but may already
|
|
449
|
+
# have reached the client live — it must not be taken for a deleted one.
|
|
450
|
+
def present_messages
|
|
451
|
+
margin = Time.current - PRESENT_MARGIN
|
|
452
|
+
ids = @conversation.messages.where(created_at: time_from_us(params[:from])..).pluck(:id)
|
|
453
|
+
|
|
454
|
+
{ ids: ids.map { |id| helpers.dom_id(Chats::Message.new(id: id)) },
|
|
455
|
+
until: (margin.to_i * 1_000_000) + margin.usec }
|
|
456
|
+
end
|
|
457
|
+
|
|
216
458
|
# A dock window asks for its thread from inside its own turbo frame. A
|
|
217
459
|
# plain visit to the same URL (no frame) still gets the page.
|
|
218
460
|
def dock_window?
|
|
@@ -57,13 +57,23 @@ module Chats
|
|
|
57
57
|
# tab docks). Subscribes to the messager's badge stream so it updates in
|
|
58
58
|
# real time without refreshing the page it sits on (see
|
|
59
59
|
# Chats::Broadcasts for why badges get their own stream).
|
|
60
|
+
#
|
|
61
|
+
# The viewer's own badge also catches up on updates its stream missed
|
|
62
|
+
# (the chats--unread-badge controller asks GET /badge on a reconnect, on
|
|
63
|
+
# a return to a hidden page, on a restored page). Its wrapper is
|
|
64
|
+
# `display: contents` in chats.css, so it changes no layout.
|
|
60
65
|
def chats_unread_badge(messager = chats_viewer)
|
|
61
66
|
return if messager.nil?
|
|
62
67
|
|
|
63
|
-
safe_join([
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
68
|
+
badge = safe_join([
|
|
69
|
+
turbo_stream_from(messager, :chats_badge),
|
|
70
|
+
render(partial: "chats/shared/unread_badge", locals: { count: messager.unread_chats_count })
|
|
71
|
+
])
|
|
72
|
+
return badge unless messager == chats_viewer
|
|
73
|
+
|
|
74
|
+
tag.span(badge, class: "chats-unread-badge-live",
|
|
75
|
+
data: { controller: "chats--unread-badge",
|
|
76
|
+
"chats--unread-badge-url-value": chats_routes.badge_conversations_path })
|
|
67
77
|
end
|
|
68
78
|
|
|
69
79
|
# The messaging dock, LinkedIn / Steam style: a bar in the bottom-right
|
|
@@ -117,6 +127,19 @@ module Chats
|
|
|
117
127
|
{ chats_dock_toggle: true }
|
|
118
128
|
end
|
|
119
129
|
|
|
130
|
+
# The CLDR plural categories JavaScript's Intl.PluralRules answers with.
|
|
131
|
+
PLURAL_CATEGORIES = %i[zero one two few many other].freeze
|
|
132
|
+
|
|
133
|
+
# Every plural form of +key+ the current locale provides, for client-side
|
|
134
|
+
# pluralization ({ one: "…", few: "…", other: "…" }). The gem ships
|
|
135
|
+
# one/other; a host adds the forms its languages need (Polish few/many,
|
|
136
|
+
# Arabic zero/two…) in its own locale files. Empty when the key is
|
|
137
|
+
# missing, never a "translation missing" string.
|
|
138
|
+
def chats_plural_forms(key)
|
|
139
|
+
forms = I18n.t(key, default: {})
|
|
140
|
+
forms.is_a?(Hash) ? forms.slice(*PLURAL_CATEGORIES) : {}
|
|
141
|
+
end
|
|
142
|
+
|
|
120
143
|
# Whether +viewer+ muted +conversation+, read from the preloaded seats
|
|
121
144
|
# (the inbox loads them), so a row costs no query for it.
|
|
122
145
|
def chats_muted?(conversation, viewer)
|