pocket_phone 0.1.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.
Files changed (34) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +14 -0
  3. data/LICENSE +28 -0
  4. data/README.md +184 -0
  5. data/app/controllers/pocket_phone/api/sendblue_controller.rb +329 -0
  6. data/app/controllers/pocket_phone/application_controller.rb +33 -0
  7. data/app/controllers/pocket_phone/conversations_controller.rb +91 -0
  8. data/app/controllers/pocket_phone/media_controller.rb +14 -0
  9. data/app/controllers/pocket_phone/messages_controller.rb +65 -0
  10. data/app/controllers/pocket_phone/people_controller.rb +19 -0
  11. data/app/helpers/pocket_phone/application_helper.rb +145 -0
  12. data/app/views/layouts/pocket_phone/_script.html.erb +183 -0
  13. data/app/views/layouts/pocket_phone/_styles.html.erb +192 -0
  14. data/app/views/layouts/pocket_phone/application.html.erb +15 -0
  15. data/app/views/pocket_phone/conversations/_head.html.erb +44 -0
  16. data/app/views/pocket_phone/conversations/_message.html.erb +132 -0
  17. data/app/views/pocket_phone/conversations/_messages.html.erb +21 -0
  18. data/app/views/pocket_phone/conversations/_new_dialog.html.erb +52 -0
  19. data/app/views/pocket_phone/conversations/_sidebar.html.erb +28 -0
  20. data/app/views/pocket_phone/conversations/show.html.erb +67 -0
  21. data/config/routes.rb +41 -0
  22. data/lib/pocket_phone/carrier.rb +119 -0
  23. data/lib/pocket_phone/configuration.rb +60 -0
  24. data/lib/pocket_phone/database.rb +157 -0
  25. data/lib/pocket_phone/engine.rb +9 -0
  26. data/lib/pocket_phone/link_preview.rb +128 -0
  27. data/lib/pocket_phone/numbers.rb +40 -0
  28. data/lib/pocket_phone/payloads.rb +50 -0
  29. data/lib/pocket_phone/runner.rb +37 -0
  30. data/lib/pocket_phone/store.rb +106 -0
  31. data/lib/pocket_phone/version.rb +5 -0
  32. data/lib/pocket_phone/webhook.rb +68 -0
  33. data/lib/pocket_phone.rb +47 -0
  34. metadata +135 -0
@@ -0,0 +1,192 @@
1
+ <style>
2
+ :root {
3
+ --bg: #ffffff; --side: #f4f4f6; --line: #e2e2e7; --text: #111113; --muted: #86868b;
4
+ --grey: #e9e9eb; --grey-text: #111113; --blue: #0b84fe; --green: #34c759; --red: #ff3b30;
5
+ --select: #0a6fe0; --card: #f1f1f3; --field: #ffffff; --shadow: 0 8px 40px rgba(0, 0, 0, .18);
6
+ color-scheme: light dark;
7
+ }
8
+ @media (prefers-color-scheme: dark) {
9
+ :root {
10
+ --bg: #1c1c1e; --side: #232326; --line: #38383c; --text: #f5f5f7; --muted: #98989f;
11
+ --grey: #3a3a3d; --grey-text: #f5f5f7; --blue: #0b84fe; --green: #30d158; --red: #ff453a;
12
+ --select: #0a6fe0; --card: #2c2c2f; --field: #1c1c1e; --shadow: 0 8px 40px rgba(0, 0, 0, .6);
13
+ }
14
+ }
15
+ * { box-sizing: border-box; }
16
+ html, body { height: 100%; margin: 0; }
17
+ body {
18
+ background: var(--bg); color: var(--text);
19
+ font: 15px/1.35 -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", Helvetica, Arial, sans-serif;
20
+ -webkit-font-smoothing: antialiased;
21
+ }
22
+ button, input, select, textarea { font: inherit; color: inherit; }
23
+ code, pre { font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; }
24
+ [hidden] { display: none !important; }
25
+
26
+ .pp { display: grid; grid-template-columns: 320px minmax(0, 1fr); height: 100dvh; }
27
+
28
+ /* --- sidebar ------------------------------------------------------------ */
29
+ .pp-sidebar { display: flex; flex-direction: column; min-height: 0; background: var(--side); border-right: 1px solid var(--line); }
30
+ .pp-sidebar-top { display: flex; align-items: center; justify-content: space-between; padding: 16px 12px 10px 18px; }
31
+ .pp-sidebar-top h1 { margin: 0; font-size: 22px; font-weight: 700; letter-spacing: -.01em; }
32
+ #pp-sidebar { flex: 1; overflow-y: auto; padding: 0 8px; }
33
+ .pp-sidebar-empty { color: var(--muted); padding: 8px 12px; margin: 0; }
34
+ .pp-sidebar-foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 10px 16px; border-top: 1px solid var(--line); color: var(--muted); font-size: 12px; }
35
+ .pp-sidebar-foot form { margin: 0; }
36
+
37
+ .pp-item { display: flex; align-items: center; gap: 10px; padding: 10px 10px 10px 4px; border-radius: 10px; color: inherit; text-decoration: none; }
38
+ .pp-item:hover { background: color-mix(in srgb, var(--text) 6%, transparent); }
39
+ .pp-item.current { background: var(--select); color: #fff; }
40
+ .pp-item-body { display: flex; flex-direction: column; min-width: 0; flex: 1; }
41
+ .pp-item-top { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
42
+ .pp-item-name { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
43
+ .pp-item-time { font-size: 12px; color: var(--muted); flex: none; }
44
+ .pp-item-snippet { font-size: 13px; color: var(--muted); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; min-height: 1.35em; }
45
+ .pp-item.current .pp-item-time, .pp-item.current .pp-item-snippet { color: rgba(255, 255, 255, .8); }
46
+ .pp-unread { width: 8px; height: 8px; border-radius: 50%; flex: none; }
47
+ .pp-unread.on { background: var(--blue); }
48
+
49
+ .pp-avatar { display: inline-grid; place-items: center; flex: none; width: 44px; height: 44px; border-radius: 50%; overflow: hidden; color: #fff; font-size: 16px; font-weight: 600; background: linear-gradient(hsl(var(--hue, 220) 28% 68%), hsl(var(--hue, 220) 28% 50%)); }
50
+ .pp-avatar img { width: 100%; height: 100%; object-fit: cover; }
51
+
52
+ /* --- thread ------------------------------------------------------------- */
53
+ .pp-thread { display: flex; flex-direction: column; min-width: 0; min-height: 0; }
54
+ .pp-head { display: flex; align-items: center; gap: 10px; padding: 10px 16px; border-bottom: 1px solid var(--line); }
55
+ .pp-back { display: none; font-size: 30px; line-height: 1; color: var(--blue); text-decoration: none; padding: 0 6px 4px 0; }
56
+ .pp-head-title { display: flex; flex-direction: column; min-width: 0; flex: 1; }
57
+ .pp-head-title span { font-size: 12px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
58
+ .pp-head-controls { display: flex; align-items: center; gap: 10px; flex: none; }
59
+ .pp-head-controls form { display: flex; align-items: center; gap: 10px; margin: 0; }
60
+ .pp-head-controls label { display: flex; flex-direction: column; gap: 2px; font-size: 11px; color: var(--muted); }
61
+ .pp-head-controls select { font-size: 13px; padding: 3px 6px; border-radius: 7px; border: 1px solid var(--line); background: var(--field); }
62
+ .pp-pill { font-size: 12px; padding: 3px 10px; border-radius: 999px; color: #fff; background: var(--green); }
63
+ .pp-pill.imessage { background: var(--blue); }
64
+ .pp-banner { padding: 8px 16px; font-size: 13px; background: color-mix(in srgb, #ffcc00 22%, var(--bg)); border-bottom: 1px solid var(--line); }
65
+
66
+ .pp-messages { flex: 1; overflow-y: auto; padding: 12px 16px 6px; display: flex; flex-direction: column; }
67
+ .pp-stamp { margin: 14px 0 6px; text-align: center; font-size: 12px; color: var(--muted); }
68
+ .pp-stamp strong { font-weight: 600; }
69
+
70
+ .pp-row { display: flex; flex-direction: column; align-items: flex-start; margin-top: 8px; }
71
+ .pp-row.continued { margin-top: 2px; }
72
+ .pp-row.mine { align-items: flex-end; }
73
+ .pp-sender { font-size: 12px; color: var(--muted); margin: 4px 0 2px 12px; }
74
+ .pp-row.mine .pp-sender { display: none; }
75
+ .pp-line { display: flex; align-items: center; gap: 6px; max-width: min(78%, 560px); }
76
+ .pp-row.mine .pp-line { flex-direction: row-reverse; }
77
+ .pp-stack { position: relative; display: flex; flex-direction: column; align-items: flex-start; gap: 3px; min-width: 0; }
78
+ .pp-row.mine .pp-stack { align-items: flex-end; }
79
+
80
+ .pp-bubble { padding: 7px 12px; border-radius: 18px; background: var(--grey); color: var(--grey-text); white-space: pre-wrap; overflow-wrap: anywhere; }
81
+ .pp-bubble a { color: inherit; }
82
+ .pp-row.mine .pp-bubble { background: var(--green); color: #fff; }
83
+ .pp-row.mine.imessage .pp-bubble { background: var(--blue); }
84
+ .pp-row.undelivered .pp-stack { opacity: .45; }
85
+
86
+ .pp-media { display: block; max-width: 280px; max-height: 320px; border-radius: 16px; }
87
+ .pp-audio { max-width: 280px; }
88
+ .pp-file { display: inline-block; padding: 8px 12px; border-radius: 14px; background: var(--card); color: inherit; text-decoration: none; }
89
+ .pp-carousel { display: flex; gap: 6px; max-width: 100%; overflow-x: auto; scroll-snap-type: x mandatory; }
90
+ .pp-carousel > * { flex: none; scroll-snap-align: start; }
91
+ .pp-carousel .pp-media { max-width: 200px; max-height: 240px; }
92
+
93
+ .pp-card { display: block; width: 280px; max-width: 100%; border-radius: 16px; overflow: hidden; background: var(--card); color: inherit; text-decoration: none; }
94
+ .pp-card-image { position: relative; }
95
+ .pp-card-image img { display: block; width: 100%; max-height: 220px; object-fit: cover; }
96
+ .pp-card-overlay { position: absolute; inset: auto 0 0 0; padding: 18px 12px 8px; color: #fff; background: linear-gradient(transparent, rgba(0, 0, 0, .6)); display: flex; flex-direction: column; }
97
+ .pp-card-body { display: flex; flex-direction: column; gap: 1px; padding: 8px 12px 9px; }
98
+ .pp-card-body strong { font-size: 14px; font-weight: 600; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
99
+ .pp-card-body span { font-size: 12px; color: var(--muted); }
100
+ .pp-card-split { flex-direction: row; justify-content: space-between; gap: 12px; }
101
+ .pp-card-split div { display: flex; flex-direction: column; }
102
+ .pp-card-split .trailing { text-align: right; }
103
+ .pp-card-app, .pp-card-warning { padding: 6px 12px 8px; font-size: 11px; color: var(--muted); border-top: 1px solid var(--line); }
104
+ .pp-card-warning { color: #b25000; background: color-mix(in srgb, #ffcc00 18%, var(--card)); }
105
+
106
+ .pp-reactions { position: absolute; top: -12px; right: -10px; display: flex; gap: 2px; padding: 2px 5px; border-radius: 999px; background: var(--card); border: 2px solid var(--bg); font-size: 12px; line-height: 1.2; }
107
+ .pp-row.mine .pp-reactions { right: auto; left: -10px; }
108
+ .pp-row:has(.pp-reactions) { margin-top: 18px; }
109
+
110
+ .pp-tools { display: flex; align-items: center; gap: 4px; flex: none; opacity: 0; transition: opacity .12s; }
111
+ .pp-row:hover .pp-tools, .pp-row.open .pp-tools { opacity: 1; }
112
+ .pp-tapbacks { display: flex; padding: 2px 4px; border-radius: 999px; background: var(--card); }
113
+ .pp-tapbacks button { border: 0; background: none; padding: 2px 3px; cursor: pointer; font-size: 14px; border-radius: 50%; }
114
+ .pp-tapbacks button:hover { transform: scale(1.25); }
115
+ .pp-info { border: 0; background: none; padding: 2px 4px; cursor: pointer; color: var(--muted); font-size: 15px; }
116
+
117
+ .pp-receipt, .pp-note { font-size: 11px; color: var(--muted); margin: 2px 6px 0; max-width: min(78%, 560px); }
118
+ .pp-receipt { font-weight: 600; }
119
+ .pp-receipt span { font-weight: 400; }
120
+ .pp-receipt.failed { color: var(--red); }
121
+
122
+ .pp-details { align-self: stretch; margin: 6px 0 4px; padding: 10px 12px; border-radius: 12px; background: var(--card); font-size: 12px; overflow-x: auto; }
123
+ .pp-details dl { display: grid; grid-template-columns: max-content 1fr; gap: 3px 12px; margin: 0; }
124
+ .pp-details dt { color: var(--muted); }
125
+ .pp-details dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
126
+ .pp-details ul { margin: 0 0 4px; padding: 0; list-style: none; }
127
+ .pp-details li span { color: var(--muted); }
128
+ .pp-details li.bad b, .pp-details li.bad { color: var(--red); }
129
+ .pp-details pre { margin: 8px 0 0; padding-top: 8px; border-top: 1px solid var(--line); white-space: pre-wrap; overflow-wrap: anywhere; }
130
+
131
+ .pp-typing { display: inline-flex; gap: 4px; padding: 11px 12px; }
132
+ .pp-typing i { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); animation: pp-blink 1.2s infinite both; }
133
+ .pp-typing i:nth-child(2) { animation-delay: .2s; }
134
+ .pp-typing i:nth-child(3) { animation-delay: .4s; }
135
+ @keyframes pp-blink { 0%, 60%, 100% { opacity: .35; } 30% { opacity: 1; } }
136
+ @media (prefers-reduced-motion: reduce) { .pp-typing i { animation: none; } }
137
+
138
+ /* --- compose ------------------------------------------------------------ */
139
+ .pp-compose { display: flex; align-items: flex-end; gap: 8px; padding: 8px 16px 14px; margin: 0; }
140
+ .pp-field { flex: 1; display: flex; align-items: flex-end; gap: 6px; min-width: 0; padding: 4px 4px 4px 12px; border: 1px solid var(--line); border-radius: 20px; background: var(--field); }
141
+ .pp-field textarea { flex: 1; min-width: 0; border: 0; outline: 0; resize: none; background: none; padding: 4px 0; max-height: 140px; }
142
+ .pp-as { border: 0; background: var(--card); border-radius: 999px; padding: 4px 8px; font-size: 12px; margin-bottom: 1px; max-width: 130px; }
143
+ .pp-attached { font-size: 12px; padding: 4px 8px; border-radius: 999px; background: var(--card); white-space: nowrap; max-width: 160px; overflow: hidden; text-overflow: ellipsis; cursor: pointer; }
144
+ .pp-send { display: grid; place-items: center; flex: none; width: 28px; height: 28px; border: 0; border-radius: 50%; background: var(--blue); color: #fff; cursor: pointer; }
145
+ .pp-compose.sms .pp-send { background: var(--green); }
146
+ .pp-icon { display: inline-grid; place-items: center; width: 34px; height: 34px; border: 0; border-radius: 50%; background: none; color: var(--blue); cursor: pointer; }
147
+ .pp-icon:hover { background: color-mix(in srgb, var(--text) 8%, transparent); }
148
+ .pp-attach { color: var(--muted); background: var(--card); margin-bottom: 2px; }
149
+
150
+ .pp-link { border: 0; background: none; padding: 0; color: var(--blue); cursor: pointer; font-size: inherit; }
151
+ .pp-button { border: 0; border-radius: 999px; padding: 8px 18px; background: var(--blue); color: #fff; font-weight: 600; cursor: pointer; }
152
+ .pp-hint { color: var(--muted); font-size: 13px; }
153
+
154
+ .pp-empty { margin: auto; max-width: 380px; padding: 24px; text-align: center; }
155
+ .pp-empty-phone { font-size: 56px; }
156
+ .pp-empty h2 { margin: 8px 0 6px; }
157
+ .pp-empty p { color: var(--muted); margin: 0 0 16px; }
158
+ .pp-empty .pp-hint { margin-top: 18px; }
159
+
160
+ /* --- new conversation --------------------------------------------------- */
161
+ .pp-dialog { width: min(440px, calc(100vw - 32px)); max-height: calc(100dvh - 48px); padding: 18px 20px 20px; border: 0; border-radius: 16px; background: var(--bg); color: var(--text); box-shadow: var(--shadow); }
162
+ .pp-dialog::backdrop { background: rgba(0, 0, 0, .35); }
163
+ .pp-dialog-top { display: flex; align-items: center; justify-content: space-between; }
164
+ .pp-dialog h2 { margin: 0; font-size: 18px; }
165
+ .pp-dialog h3 { margin: 18px 0 8px; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); }
166
+ .pp-people { display: flex; flex-direction: column; gap: 2px; }
167
+ .pp-people form { margin: 0; }
168
+ .pp-person { display: flex; align-items: center; gap: 10px; width: 100%; padding: 6px 8px; border: 0; border-radius: 10px; background: none; text-align: left; cursor: pointer; }
169
+ .pp-person:hover { background: var(--card); }
170
+ .pp-person span:last-child { display: flex; flex-direction: column; }
171
+ .pp-person small { color: var(--muted); }
172
+ .pp-form { display: flex; flex-direction: column; gap: 10px; margin: 0; }
173
+ .pp-form > label { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: var(--muted); }
174
+ .pp-form input[type="text"], .pp-form select { padding: 7px 10px; border: 1px solid var(--line); border-radius: 9px; background: var(--field); color: var(--text); }
175
+ .pp-form .pp-button { align-self: flex-start; }
176
+ .pp-checks { display: flex; flex-wrap: wrap; gap: 6px 16px; }
177
+
178
+ @media (max-width: 720px) {
179
+ .pp { grid-template-columns: minmax(0, 1fr); }
180
+ .pp.pp-has-thread .pp-sidebar, .pp:not(.pp-has-thread) .pp-thread { display: none; }
181
+ .pp:not(.pp-has-thread) .pp-sidebar { border-right: 0; }
182
+ .pp-back { display: block; }
183
+ .pp-head { flex-wrap: wrap; padding: 8px 12px; }
184
+ .pp-head-controls { width: 100%; justify-content: flex-end; }
185
+ .pp-messages { padding: 12px 12px 6px; }
186
+ .pp-compose { padding: 8px 12px 12px; }
187
+ .pp-line { max-width: 88%; }
188
+ .pp-tools { opacity: 1; }
189
+ .pp-tapbacks { display: none; }
190
+ .pp-row.open .pp-tapbacks { display: flex; }
191
+ }
192
+ </style>
@@ -0,0 +1,15 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Pocket Phone</title>
7
+ <%= csrf_meta_tags %>
8
+ <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>📱</text></svg>">
9
+ <%= render "layouts/pocket_phone/styles" %>
10
+ </head>
11
+ <body>
12
+ <%= yield %>
13
+ <%= render "layouts/pocket_phone/script" %>
14
+ </body>
15
+ </html>
@@ -0,0 +1,44 @@
1
+ <% group = @db.group?(@conversation) %>
2
+ <% line = @db.lines[@conversation["line"]] || {} %>
3
+ <% service = @db.service_for(@conversation) %>
4
+ <header class="pp-head" data-service="<%= service %>">
5
+ <a href="<%= root_path(list: 1) %>" class="pp-back" aria-label="Back">‹</a>
6
+ <%= pp_avatar(group ? "👥" : pp_title(@conversation), key: @conversation["id"], size: 36) %>
7
+ <div class="pp-head-title">
8
+ <strong><%= pp_title(@conversation) %></strong>
9
+ <span>
10
+ <% if group %>
11
+ <%= @conversation["participants"].map { |n| pp_person_name(n) }.join(", ") %>
12
+ <% else %>
13
+ <%= PocketPhone::Numbers.pretty(@conversation["number"]) %>
14
+ <% end %>
15
+ · texting <%= pp_line_name(@conversation["line"]) %>
16
+ <% if line["name"].present? %>(<%= PocketPhone::Numbers.pretty(@conversation["line"]) %>)<% end %>
17
+ </span>
18
+ </div>
19
+
20
+ <div class="pp-head-controls">
21
+ <% if group %>
22
+ <span class="pp-pill <%= service.downcase %>"><%= service == "iMessage" ? "iMessage group" : "Group MMS" %></span>
23
+ <% else %>
24
+ <% person = @db.people[@conversation["number"]] || {} %>
25
+ <%= form_with url: people_path, method: :post, data: { remote: true } do %>
26
+ <%= hidden_field_tag :number, @conversation["number"] %>
27
+ <label>Phone
28
+ <%= select_tag :service, options_for_select([["iPhone (iMessage)", "iMessage"], ["Android (SMS)", "SMS"], ["Android (RCS)", "RCS"]], person["service"]), data: { autosubmit: true } %>
29
+ </label>
30
+ <label>Sends to it
31
+ <%= select_tag :outcome, options_for_select([["are delivered", "delivered"], ["fail after sending", "failed"], ["are refused", "rejected"]], person["outcome"]), data: { autosubmit: true } %>
32
+ </label>
33
+ <% end %>
34
+ <% end %>
35
+ <%= form_with url: delete_conversation_path(@conversation["id"]), method: :post, data: { confirm: "Delete this conversation?" } do %>
36
+ <button type="submit" class="pp-icon" title="Delete conversation" aria-label="Delete conversation">
37
+ <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M10 11v6M14 11v6M6 7l1 13h10l1-13M9 7V4h6v3"/></svg>
38
+ </button>
39
+ <% end %>
40
+ </div>
41
+ </header>
42
+ <% unless PocketPhone.config.webhook_url %>
43
+ <div class="pp-banner">No <code>webhook_url</code> is configured, so texts from this phone will not reach your app.</div>
44
+ <% end %>
@@ -0,0 +1,132 @@
1
+ <%# locals: message, group, last_phone, continued %>
2
+ <% phone = message["direction"] == "phone" %>
3
+ <% link = pp_preview_for(message) %>
4
+ <% receipt = phone ? pp_receipt(message, @conversation, last: last_phone) : nil %>
5
+ <% undelivered = !phone && message["status"] == "ERROR" %>
6
+ <% text = message["content"].to_s %>
7
+ <% only_link = link[:card] && text.strip == link[:url] %>
8
+ <% card = message["app_card"] %>
9
+ <div class="pp-row <%= "mine" if phone && !group %> <%= message["service"].to_s.downcase %> <%= "undelivered" if undelivered %> <%= "continued" if continued %>"
10
+ data-handle="<%= message["handle"] %>" data-from="<%= phone ? message["from_number"] : "line" %>">
11
+ <% if group && !continued %>
12
+ <div class="pp-sender"><%= pp_sender_name(message) %></div>
13
+ <% end %>
14
+
15
+ <div class="pp-line">
16
+ <div class="pp-stack">
17
+ <% if message["media_urls"].present? %>
18
+ <div class="pp-carousel">
19
+ <% message["media_urls"].each do |url| %><%= pp_media(url) %><% end %>
20
+ </div>
21
+ <% end %>
22
+ <% if message["media_url"].present? %>
23
+ <%= pp_media(message["media_url"]) %>
24
+ <% end %>
25
+
26
+ <% if card %>
27
+ <% layout = card["layout"] || {} %>
28
+ <div class="pp-card pp-appcard" title="<%= card["url"] %>">
29
+ <% if layout["imageUrl"].present? %>
30
+ <div class="pp-card-image">
31
+ <%= image_tag layout["imageUrl"], alt: "" %>
32
+ <% if layout["imageTitle"].present? || layout["imageSubtitle"].present? %>
33
+ <div class="pp-card-overlay"><strong><%= layout["imageTitle"] %></strong><span><%= layout["imageSubtitle"] %></span></div>
34
+ <% end %>
35
+ </div>
36
+ <% end %>
37
+ <div class="pp-card-body pp-card-split">
38
+ <div><strong><%= layout["caption"] %></strong><span><%= layout["subcaption"] %></span></div>
39
+ <div class="trailing"><strong><%= layout["trailingCaption"] %></strong><span><%= layout["trailingSubcaption"] %></span></div>
40
+ </div>
41
+ <div class="pp-card-app"><%= card["appName"] %> · app card, shown as it looks without the extension installed</div>
42
+ </div>
43
+ <% end %>
44
+
45
+ <% if text.present? && !only_link %>
46
+ <div class="pp-bubble"><%= pp_linkify(text) %></div>
47
+ <% end %>
48
+
49
+ <% if link[:card] %>
50
+ <% preview = link[:card] %>
51
+ <a class="pp-card" href="<%= preview["url"] %>" target="_blank" rel="noopener">
52
+ <% if preview["image"].present? %>
53
+ <div class="pp-card-image"><%= image_tag preview["image"], alt: "" %></div>
54
+ <% end %>
55
+ <div class="pp-card-body">
56
+ <strong><%= preview["title"].presence || preview["host"] %></strong>
57
+ <span><%= preview["host"] %></span>
58
+ </div>
59
+ <% if preview["local"] %>
60
+ <div class="pp-card-warning">A real phone could not load this preview: <%= preview["host"] %> is only reachable from your machine.</div>
61
+ <% end %>
62
+ </a>
63
+ <% end %>
64
+
65
+ <% if message["reactions"].present? %>
66
+ <div class="pp-reactions">
67
+ <% message["reactions"].each do |reaction| %>
68
+ <span title="<%= reaction["by"] == "line" ? pp_line_name(@conversation["line"]) : pp_person_name(reaction["by"]) %>"><%= pp_tapback(reaction["reaction"]) %></span>
69
+ <% end %>
70
+ </div>
71
+ <% end %>
72
+ </div>
73
+
74
+ <div class="pp-tools">
75
+ <% if !phone && !undelivered && message["service"] == "iMessage" %>
76
+ <span class="pp-tapbacks">
77
+ <% PocketPhone::ApplicationHelper::TAPBACKS.each do |name, glyph| %>
78
+ <button type="button" data-tapback="<%= name %>" title="<%= name.capitalize %>"><%= glyph %></button>
79
+ <% end %>
80
+ </span>
81
+ <% end %>
82
+ <button type="button" class="pp-info" data-details title="Details" aria-label="Details">ⓘ</button>
83
+ </div>
84
+ </div>
85
+
86
+ <% if receipt %>
87
+ <div class="pp-receipt <%= "failed" if receipt[:failed] %>">
88
+ <%= receipt[:text] %><% if receipt[:detail] %> <span>· <%= receipt[:detail] %></span><% end %>
89
+ </div>
90
+ <% end %>
91
+ <% if undelivered %>
92
+ <div class="pp-receipt failed">Never reached this phone <span>· <%= message["error_message"] %></span></div>
93
+ <% end %>
94
+ <% if message["send_style"].present? %>
95
+ <div class="pp-note">Sent with <%= message["send_style"].to_s.tr("_", " ") %></div>
96
+ <% end %>
97
+ <% if link[:reason] %>
98
+ <div class="pp-note">No link preview: <%= link[:reason] %>.</div>
99
+ <% elsif link[:preview] && link[:preview]["error"] %>
100
+ <div class="pp-note">No link preview: the page could not be fetched (<%= link[:preview]["error"] %>).</div>
101
+ <% end %>
102
+
103
+ <div class="pp-details" hidden>
104
+ <dl>
105
+ <dt>Handle</dt><dd><code><%= message["handle"] %></code></dd>
106
+ <dt>Status</dt><dd><%= message["status"] %><% if message["error_code"] %> (<%= message["error_code"] %>)<% end %></dd>
107
+ <dt>Service</dt><dd><%= message["service"] %></dd>
108
+ <dt>From → to</dt><dd><%= message["from_number"] %> → <%= message["to_number"].presence || @conversation["group_id"] %></dd>
109
+ <dt>Sent</dt><dd><%= message["created_at"] %></dd>
110
+ <dt>Webhooks</dt>
111
+ <dd>
112
+ <% if message["webhooks"].blank? %>
113
+ none sent
114
+ <% else %>
115
+ <ul>
116
+ <% message["webhooks"].each do |hook| %>
117
+ <li class="<%= "bad" unless hook["status"].to_i.between?(200, 299) %>">
118
+ <b><%= hook["type"] %></b> → <%= pp_webhook_outcome(hook) %> <span><%= hook["url"] %></span>
119
+ </li>
120
+ <% end %>
121
+ </ul>
122
+ <% end %>
123
+ <% if phone %>
124
+ <button type="button" class="pp-link" data-redeliver>Send the webhook again</button>
125
+ <% end %>
126
+ </dd>
127
+ </dl>
128
+ <% if message["request"].present? %>
129
+ <pre><%= pp_json(message["request"]) %></pre>
130
+ <% end %>
131
+ </div>
132
+ </div>
@@ -0,0 +1,21 @@
1
+ <% messages = @db.messages_for(@conversation) %>
2
+ <% group = @db.group?(@conversation) %>
3
+ <% last_phone = messages.reverse.find { |m| m["direction"] == "phone" } %>
4
+ <% if messages.empty? %>
5
+ <p class="pp-stamp">No messages yet. Say something.</p>
6
+ <% end %>
7
+ <% previous = nil %>
8
+ <% messages.each do |message| %>
9
+ <% if pp_gap?(previous, message) %>
10
+ <p class="pp-stamp"><%= pp_stamp(message["created_at"]) %></p>
11
+ <% end %>
12
+ <%= render "pocket_phone/conversations/message", message: message, group: group,
13
+ last_phone: last_phone.equal?(message),
14
+ continued: previous && !pp_gap?(previous, message) && previous["from_number"] == message["from_number"] %>
15
+ <% previous = message %>
16
+ <% end %>
17
+ <% if @db.typing?(@conversation) %>
18
+ <div class="pp-row" data-from="line">
19
+ <div class="pp-bubble pp-typing" aria-label="Typing"><i></i><i></i><i></i></div>
20
+ </div>
21
+ <% end %>
@@ -0,0 +1,52 @@
1
+ <% people = @db.people.values %>
2
+ <dialog class="pp-dialog" data-dialog>
3
+ <div class="pp-dialog-top">
4
+ <h2>New conversation</h2>
5
+ <button type="button" class="pp-icon" data-close-dialog aria-label="Close">✕</button>
6
+ </div>
7
+
8
+ <% if people.any? %>
9
+ <section>
10
+ <h3>Text as someone you already have</h3>
11
+ <div class="pp-people">
12
+ <% people.each do |person| %>
13
+ <%= form_with url: conversations_path, method: :post do %>
14
+ <%= hidden_field_tag :number, person["number"] %>
15
+ <button type="submit" class="pp-person">
16
+ <%= pp_avatar(pp_person_name(person["number"]), key: person["number"], size: 28) %>
17
+ <span><strong><%= pp_person_name(person["number"]) %></strong><small><%= PocketPhone::Numbers.pretty(person["number"]) %> · <%= person["service"] %></small></span>
18
+ </button>
19
+ <% end %>
20
+ <% end %>
21
+ </div>
22
+ </section>
23
+ <% end %>
24
+
25
+ <section>
26
+ <h3>A new person</h3>
27
+ <%= form_with url: conversations_path, method: :post, class: "pp-form" do %>
28
+ <label>Name <%= text_field_tag :name, nil, placeholder: "Alice", autocomplete: "off" %></label>
29
+ <label>Number <%= text_field_tag :number, PocketPhone::Numbers.random, required: true, autocomplete: "off" %></label>
30
+ <label>Phone <%= select_tag :service, options_for_select([["iPhone (iMessage)", "iMessage"], ["Android (SMS)", "SMS"], ["Android (RCS)", "RCS"]]) %></label>
31
+ <button type="submit" class="pp-button">Start texting</button>
32
+ <% end %>
33
+ </section>
34
+
35
+ <% if people.size >= 2 %>
36
+ <section>
37
+ <h3>A group</h3>
38
+ <%= form_with url: conversations_path, method: :post, class: "pp-form" do %>
39
+ <%= hidden_field_tag :group, 1 %>
40
+ <label>Group name <%= text_field_tag :group_name, nil, placeholder: "Optional", autocomplete: "off" %></label>
41
+ <div class="pp-checks">
42
+ <% people.each do |person| %>
43
+ <label><%= check_box_tag "participants[]", person["number"], false, id: nil %> <%= pp_person_name(person["number"]) %></label>
44
+ <% end %>
45
+ </div>
46
+ <button type="submit" class="pp-button">Start group</button>
47
+ <% end %>
48
+ </section>
49
+ <% else %>
50
+ <p class="pp-hint">Add two people and you can put them in a group chat.</p>
51
+ <% end %>
52
+ </dialog>
@@ -0,0 +1,28 @@
1
+ <% conversations = @db.sorted_conversations %>
2
+ <% if conversations.empty? %>
3
+ <p class="pp-sidebar-empty">Nothing here yet.</p>
4
+ <% end %>
5
+ <% conversations.each do |conversation| %>
6
+ <% last = @db.messages_for(conversation).last %>
7
+ <% current = @conversation && @conversation["id"] == conversation["id"] %>
8
+ <% title = pp_title(conversation) %>
9
+ <a href="<%= conversation_path(conversation["id"]) %>" class="pp-item <%= "current" if current %>">
10
+ <span class="pp-unread <%= "on" if conversation["unread"].to_i.positive? && !current %>"></span>
11
+ <%= pp_avatar(@db.group?(conversation) ? "👥" : title, key: conversation["id"]) %>
12
+ <span class="pp-item-body">
13
+ <span class="pp-item-top">
14
+ <span class="pp-item-name"><%= title %></span>
15
+ <span class="pp-item-time"><%= pp_clock(last ? last["created_at"] : conversation["updated_at"]) %></span>
16
+ </span>
17
+ <span class="pp-item-snippet">
18
+ <% if @db.typing?(conversation) %>
19
+ <em>typing…</em>
20
+ <% elsif last %>
21
+ <%= pp_snippet(last) %>
22
+ <% else %>
23
+ <em>No messages yet</em>
24
+ <% end %>
25
+ </span>
26
+ </span>
27
+ </a>
28
+ <% end %>
@@ -0,0 +1,67 @@
1
+ <% group = @conversation && @db.group?(@conversation) %>
2
+ <div class="pp <%= "pp-has-thread" if @conversation %>" data-pp
3
+ data-base="<%= pp_base %>"
4
+ data-conversation="<%= @conversation && @conversation["id"] %>"
5
+ data-group="<%= group ? 1 : 0 %>">
6
+ <aside class="pp-sidebar">
7
+ <div class="pp-sidebar-top">
8
+ <h1>Messages</h1>
9
+ <button type="button" class="pp-icon" data-open-dialog title="New conversation" aria-label="New conversation">
10
+ <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>
11
+ </button>
12
+ </div>
13
+ <div id="pp-sidebar"><%= render "pocket_phone/conversations/sidebar" %></div>
14
+ <div class="pp-sidebar-foot">
15
+ <%= form_with url: clear_path, method: :post, data: { confirm: "Clear every conversation?" } do %>
16
+ <button type="submit" class="pp-link">Clear conversations</button>
17
+ <% end %>
18
+ <span>pocket_phone <%= PocketPhone::VERSION %></span>
19
+ </div>
20
+ </aside>
21
+
22
+ <main class="pp-thread">
23
+ <% if @conversation %>
24
+ <div id="pp-head"><%= render "pocket_phone/conversations/head" %></div>
25
+ <div id="pp-messages" class="pp-messages"><%= render "pocket_phone/conversations/messages" %></div>
26
+
27
+ <%= form_with url: conversation_messages_path(@conversation["id"]), method: :post, multipart: true,
28
+ class: "pp-compose", data: { compose: true } do %>
29
+ <label class="pp-icon pp-attach" title="Attach a file">
30
+ <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></svg>
31
+ <input type="file" name="file" hidden>
32
+ </label>
33
+ <div class="pp-field">
34
+ <% if group %>
35
+ <select name="as" class="pp-as" data-persona title="Who is texting">
36
+ <% @conversation["participants"].each do |number| %>
37
+ <option value="<%= number %>"><%= pp_person_name(number) %></option>
38
+ <% end %>
39
+ </select>
40
+ <% end %>
41
+ <span class="pp-attached" data-attached hidden></span>
42
+ <textarea name="content" rows="1" placeholder="<%= @db.service_for(@conversation) == "iMessage" ? "iMessage" : "Text Message" %>" autofocus></textarea>
43
+ <button type="submit" class="pp-send" aria-label="Send">
44
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5M5 12l7-7 7 7"/></svg>
45
+ </button>
46
+ </div>
47
+ <% end %>
48
+ <% else %>
49
+ <div class="pp-empty">
50
+ <div class="pp-empty-phone">📱</div>
51
+ <% if @db.conversations.any? %>
52
+ <h2>Pick a conversation</h2>
53
+ <p>Or start a new one.</p>
54
+ <% else %>
55
+ <h2>No conversations yet</h2>
56
+ <p>Messages your app sends through Sendblue will show up here. Or pick up the phone and text it first.</p>
57
+ <% end %>
58
+ <button type="button" class="pp-button" data-open-dialog>New conversation</button>
59
+ <% unless PocketPhone.config.webhook_url %>
60
+ <p class="pp-hint">No <code>webhook_url</code> is configured, so texts from this phone will not reach your app. See the README.</p>
61
+ <% end %>
62
+ </div>
63
+ <% end %>
64
+ </main>
65
+
66
+ <%= render "pocket_phone/conversations/new_dialog" %>
67
+ </div>
data/config/routes.rb ADDED
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ PocketPhone::Engine.routes.draw do
4
+ # --- the phone ------------------------------------------------------------
5
+ root to: "conversations#index"
6
+ get "state" => "conversations#state"
7
+ post "clear" => "conversations#clear", as: :clear
8
+ post "conversations" => "conversations#create", as: :conversations
9
+ get "conversations/:id" => "conversations#show", as: :conversation
10
+ post "conversations/:id/delete" => "conversations#destroy", as: :delete_conversation
11
+ post "conversations/:id/typing" => "conversations#typing"
12
+ post "conversations/:id/messages" => "messages#create", as: :conversation_messages
13
+ post "messages/:handle/reactions" => "messages#react"
14
+ post "messages/:handle/redeliver" => "messages#redeliver"
15
+ post "people" => "people#update", as: :people
16
+ get "media/:name" => "media#show", as: :media, constraints: { name: %r{[^/]+} }
17
+
18
+ # --- the fake Sendblue API ------------------------------------------------
19
+ # Point your Sendblue client's base URL at wherever this engine is mounted
20
+ # and these answer in place of https://api.sendblue.com.
21
+ scope module: :api, defaults: { format: :json } do
22
+ post "api/send-message" => "sendblue#send_message"
23
+ post "api/send-group-message" => "sendblue#send_group_message"
24
+ post "api/send-carousel" => "sendblue#send_carousel"
25
+ post "api/send-typing-indicator" => "sendblue#send_typing_indicator"
26
+ post "api/send-reaction" => "sendblue#send_reaction"
27
+ post "api/mark-read" => "sendblue#mark_read"
28
+ post "api/modify-group" => "sendblue#modify_group"
29
+ get "api/evaluate-service" => "sendblue#evaluate_service"
30
+ post "api/upload-file" => "sendblue#upload_file"
31
+ post "api/upload-media-object" => "sendblue#upload_media_object"
32
+ post "api/v2/contact-sharing/profile" => "sendblue#contact_profile"
33
+ post "api/v2/contact-profile" => "sendblue#contact_profile"
34
+ get "api/v2/messages" => "sendblue#messages"
35
+ get "api/v2/messages/:handle" => "sendblue#message"
36
+ delete "api/v2/messages/:handle" => "sendblue#delete_message"
37
+ delete "api/message/:handle" => "sendblue#delete_message"
38
+ match "api/*path" => "sendblue#missing", via: :all
39
+ match "accounts/*path" => "sendblue#missing", via: :all
40
+ end
41
+ end