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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +14 -0
- data/LICENSE +28 -0
- data/README.md +184 -0
- data/app/controllers/pocket_phone/api/sendblue_controller.rb +329 -0
- data/app/controllers/pocket_phone/application_controller.rb +33 -0
- data/app/controllers/pocket_phone/conversations_controller.rb +91 -0
- data/app/controllers/pocket_phone/media_controller.rb +14 -0
- data/app/controllers/pocket_phone/messages_controller.rb +65 -0
- data/app/controllers/pocket_phone/people_controller.rb +19 -0
- data/app/helpers/pocket_phone/application_helper.rb +145 -0
- data/app/views/layouts/pocket_phone/_script.html.erb +183 -0
- data/app/views/layouts/pocket_phone/_styles.html.erb +192 -0
- data/app/views/layouts/pocket_phone/application.html.erb +15 -0
- data/app/views/pocket_phone/conversations/_head.html.erb +44 -0
- data/app/views/pocket_phone/conversations/_message.html.erb +132 -0
- data/app/views/pocket_phone/conversations/_messages.html.erb +21 -0
- data/app/views/pocket_phone/conversations/_new_dialog.html.erb +52 -0
- data/app/views/pocket_phone/conversations/_sidebar.html.erb +28 -0
- data/app/views/pocket_phone/conversations/show.html.erb +67 -0
- data/config/routes.rb +41 -0
- data/lib/pocket_phone/carrier.rb +119 -0
- data/lib/pocket_phone/configuration.rb +60 -0
- data/lib/pocket_phone/database.rb +157 -0
- data/lib/pocket_phone/engine.rb +9 -0
- data/lib/pocket_phone/link_preview.rb +128 -0
- data/lib/pocket_phone/numbers.rb +40 -0
- data/lib/pocket_phone/payloads.rb +50 -0
- data/lib/pocket_phone/runner.rb +37 -0
- data/lib/pocket_phone/store.rb +106 -0
- data/lib/pocket_phone/version.rb +5 -0
- data/lib/pocket_phone/webhook.rb +68 -0
- data/lib/pocket_phone.rb +47 -0
- metadata +135 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PocketPhone
|
|
4
|
+
class ConversationsController < ApplicationController
|
|
5
|
+
def index
|
|
6
|
+
@db = store.read
|
|
7
|
+
latest = @db.sorted_conversations.first
|
|
8
|
+
return redirect_to(conversation_path(latest["id"])) if latest && params[:list].blank?
|
|
9
|
+
|
|
10
|
+
render :show
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def show
|
|
14
|
+
load_conversation(params[:id])
|
|
15
|
+
redirect_to root_path unless @conversation
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# What the page polls. Answers 204 while nothing has changed, and the
|
|
19
|
+
# freshly rendered pieces of the page when something has.
|
|
20
|
+
def state
|
|
21
|
+
load_conversation(params[:conversation])
|
|
22
|
+
token = "#{@db.version}:#{@db.typing?(@conversation) ? 1 : 0}"
|
|
23
|
+
return head(:no_content) if params[:version] == token
|
|
24
|
+
|
|
25
|
+
render json: {
|
|
26
|
+
version: token,
|
|
27
|
+
gone: params[:conversation].present? && @conversation.nil?,
|
|
28
|
+
sidebar: render_to_string(partial: "pocket_phone/conversations/sidebar", formats: [:html]),
|
|
29
|
+
head: @conversation && render_to_string(partial: "pocket_phone/conversations/head", formats: [:html]),
|
|
30
|
+
messages: @conversation && render_to_string(partial: "pocket_phone/conversations/messages", formats: [:html])
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# A phone starts a conversation with the app's line: one person, or a
|
|
35
|
+
# group of them.
|
|
36
|
+
def create
|
|
37
|
+
conversation = store.write do |db|
|
|
38
|
+
line = Numbers.normalize(params[:line]).presence || default_line(db)
|
|
39
|
+
db.ensure_line(line)
|
|
40
|
+
params[:group].present? ? create_group(db, line) : create_direct(db, line)
|
|
41
|
+
end
|
|
42
|
+
redirect_to(conversation ? conversation_path(conversation["id"]) : root_path)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def typing
|
|
46
|
+
conversation = store.read.conversations[params[:id]]
|
|
47
|
+
if conversation && conversation["kind"] == "direct"
|
|
48
|
+
Carrier.typing_to_app(conversation, is_typing: params[:is_typing].to_s != "false")
|
|
49
|
+
end
|
|
50
|
+
head :no_content
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def destroy
|
|
54
|
+
store.write { |db| db.delete_conversation(params[:id]) }
|
|
55
|
+
redirect_to root_path
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def clear
|
|
59
|
+
store.clear!
|
|
60
|
+
redirect_to root_path
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def load_conversation(id)
|
|
66
|
+
@db = store.read
|
|
67
|
+
@conversation = id.present? ? @db.conversations[id] : nil
|
|
68
|
+
return unless @conversation && @conversation["unread"].to_i.positive?
|
|
69
|
+
|
|
70
|
+
# Looking at a thread is reading it.
|
|
71
|
+
store.write { |db| db.conversations[id]&.store("unread", 0) }
|
|
72
|
+
@db = store.read
|
|
73
|
+
@conversation = @db.conversations[id]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def create_direct(db, line)
|
|
77
|
+
number = Numbers.normalize(params[:number])
|
|
78
|
+
return nil if number.empty?
|
|
79
|
+
|
|
80
|
+
db.ensure_person(number, "name" => params[:name], "service" => params[:service])
|
|
81
|
+
db.direct(line, number)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def create_group(db, line)
|
|
85
|
+
participants = Array(params[:participants]).map { |n| Numbers.normalize(n) }.reject(&:empty?).uniq
|
|
86
|
+
return nil if participants.size < 2
|
|
87
|
+
|
|
88
|
+
db.create_group(line: line, participants: participants, name: params[:group_name])
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PocketPhone
|
|
4
|
+
# Attachments sent from the phone, or uploaded through the fake API. Served
|
|
5
|
+
# from here so a media_url in a webhook is one the app can really fetch.
|
|
6
|
+
class MediaController < ApplicationController
|
|
7
|
+
def show
|
|
8
|
+
file = store.media_file(params[:name])
|
|
9
|
+
return head(:not_found) unless file
|
|
10
|
+
|
|
11
|
+
send_file file, disposition: "inline"
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PocketPhone
|
|
4
|
+
# What the person holding the phone does: text, tapback, try again.
|
|
5
|
+
class MessagesController < ApplicationController
|
|
6
|
+
def create
|
|
7
|
+
upload = params[:file].presence
|
|
8
|
+
media_url = upload && media_url_for(store.save_media(upload, upload.original_filename))
|
|
9
|
+
content = params[:content].to_s.strip
|
|
10
|
+
return head(:unprocessable_entity) if content.empty? && media_url.nil?
|
|
11
|
+
|
|
12
|
+
message = store.write do |db|
|
|
13
|
+
conversation = db.conversations[params[:id]] or next
|
|
14
|
+
from = sender(db, conversation) or next
|
|
15
|
+
db.add_message(conversation, "direction" => "phone", "status" => "RECEIVED",
|
|
16
|
+
"from_number" => from, "to_number" => conversation["line"],
|
|
17
|
+
"content" => content, "media_url" => media_url, "service" => db.service_for(conversation))
|
|
18
|
+
end
|
|
19
|
+
return head(:not_found) unless message
|
|
20
|
+
|
|
21
|
+
Carrier.deliver_to_app(message["handle"])
|
|
22
|
+
Carrier.fetch_preview(message["handle"])
|
|
23
|
+
head :no_content
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# A tapback on something the app sent. Tapping the same one again takes it
|
|
27
|
+
# back, as on a phone.
|
|
28
|
+
def react
|
|
29
|
+
reaction = params[:reaction].to_s
|
|
30
|
+
outcome, by = store.write do |db|
|
|
31
|
+
message = db.message(params[:handle]) or next
|
|
32
|
+
conversation = db.conversations[message["conversation_id"]] or next
|
|
33
|
+
next unless message["direction"] == "app" && message["service"] == "iMessage"
|
|
34
|
+
|
|
35
|
+
by = sender(db, conversation) or next
|
|
36
|
+
[db.react(message, by: by, reaction: reaction), by]
|
|
37
|
+
end
|
|
38
|
+
return head(:unprocessable_entity) unless outcome
|
|
39
|
+
|
|
40
|
+
Carrier.reaction_to_app(params[:handle], by: by, reaction: reaction) if outcome == :added
|
|
41
|
+
head :no_content
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Send the receive webhook again, as Sendblue does after a 5xx. Handy for
|
|
45
|
+
# checking that a redelivery is not answered twice.
|
|
46
|
+
def redeliver
|
|
47
|
+
message = store.read.message(params[:handle])
|
|
48
|
+
return head(:not_found) unless message && message["direction"] == "phone"
|
|
49
|
+
|
|
50
|
+
Carrier.deliver_to_app(message["handle"])
|
|
51
|
+
head :no_content
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# Who is talking. A one-to-one has one phone in it; in a group the page
|
|
57
|
+
# says which participant it is speaking as.
|
|
58
|
+
def sender(db, conversation)
|
|
59
|
+
return conversation["number"] unless db.group?(conversation)
|
|
60
|
+
|
|
61
|
+
chosen = Numbers.normalize(params[:as])
|
|
62
|
+
conversation["participants"].include?(chosen) ? chosen : conversation["participants"].first
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PocketPhone
|
|
4
|
+
# The fake people: their names, whether they have an iPhone, and what
|
|
5
|
+
# becomes of a message sent to them.
|
|
6
|
+
class PeopleController < ApplicationController
|
|
7
|
+
def update
|
|
8
|
+
number = Numbers.normalize(params[:number])
|
|
9
|
+
unless number.empty?
|
|
10
|
+
store.write do |db|
|
|
11
|
+
person = db.ensure_person(number, "service" => params[:service], "outcome" => params[:outcome])
|
|
12
|
+
person["name"] = params[:name].to_s.strip.presence if params.key?(:name)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
request.xhr? ? head(:no_content) : redirect_back(fallback_location: root_path)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PocketPhone
|
|
4
|
+
module ApplicationHelper
|
|
5
|
+
TAPBACKS = { "love" => "❤️", "like" => "👍", "dislike" => "👎",
|
|
6
|
+
"laugh" => "😂", "emphasize" => "‼️", "question" => "❓" }.freeze
|
|
7
|
+
IMAGE = /\.(png|jpe?g|gif|webp|heic|avif|svg|bmp)\z/i
|
|
8
|
+
VIDEO = /\.(mp4|mov|m4v|webm)\z/i
|
|
9
|
+
AUDIO = /\.(mp3|m4a|wav|caf|aac|ogg|amr)\z/i
|
|
10
|
+
# Minutes of quiet before iMessage puts a timestamp between two messages.
|
|
11
|
+
TIMESTAMP_GAP = 15 * 60
|
|
12
|
+
|
|
13
|
+
def pp_base = root_path.chomp("/")
|
|
14
|
+
|
|
15
|
+
# --- names ----------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
def pp_person_name(number)
|
|
18
|
+
@db.people.dig(number, "name").presence || Numbers.pretty(number)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def pp_line_name(line)
|
|
22
|
+
@db.lines.dig(line, "name").presence || Numbers.pretty(line)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def pp_sender_name(message)
|
|
26
|
+
message["direction"] == "app" ? pp_line_name(message["from_number"]) : pp_person_name(message["from_number"])
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def pp_title(conversation)
|
|
30
|
+
return pp_person_name(conversation["number"]) unless @db.group?(conversation)
|
|
31
|
+
|
|
32
|
+
conversation["name"].presence || conversation["participants"].map { |n| pp_person_name(n).split.first }.to_sentence
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def pp_avatar(label, key: label, photo: nil, size: nil)
|
|
36
|
+
style = "--hue:#{key.to_s.sum % 360};"
|
|
37
|
+
style += "width:#{size}px;height:#{size}px;font-size:#{(size * 0.4).round}px;" if size
|
|
38
|
+
return tag.span(image_tag(photo, alt: ""), class: "pp-avatar", style: style) if photo.present?
|
|
39
|
+
|
|
40
|
+
letters = label.to_s.scan(/\b[[:alpha:]]/).first(2).join.upcase
|
|
41
|
+
letters = label.to_s.match?(/\p{Emoji_Presentation}/) ? label : "#" if letters.empty?
|
|
42
|
+
tag.span(letters, class: "pp-avatar", style: style)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# --- time -----------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
def pp_clock(iso)
|
|
48
|
+
Time.iso8601(iso).localtime.strftime("%-l:%M %p")
|
|
49
|
+
rescue ArgumentError, TypeError
|
|
50
|
+
""
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def pp_stamp(iso)
|
|
54
|
+
time = Time.iso8601(iso).localtime
|
|
55
|
+
day = if time.to_date == Date.today then "Today"
|
|
56
|
+
elsif time.to_date == Date.today - 1 then "Yesterday"
|
|
57
|
+
else time.strftime("%a, %b %-d")
|
|
58
|
+
end
|
|
59
|
+
safe_join([tag.strong(day), " ", time.strftime("%-l:%M %p")])
|
|
60
|
+
rescue ArgumentError, TypeError
|
|
61
|
+
""
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def pp_gap?(previous, message)
|
|
65
|
+
return true if previous.nil?
|
|
66
|
+
|
|
67
|
+
Time.iso8601(message["created_at"]) - Time.iso8601(previous["created_at"]) > TIMESTAMP_GAP
|
|
68
|
+
rescue ArgumentError, TypeError
|
|
69
|
+
false
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# --- message bodies -------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
def pp_linkify(text)
|
|
75
|
+
pieces = text.to_s.split(/(https?:\/\/[^\s<>"]+)/i).map do |piece|
|
|
76
|
+
next piece unless piece.match?(/\Ahttps?:\/\//i)
|
|
77
|
+
|
|
78
|
+
url = piece.sub(LinkPreview::TRAILING, "")
|
|
79
|
+
safe_join([link_to(url, url, target: "_blank", rel: "noopener"), piece[url.length..]])
|
|
80
|
+
end
|
|
81
|
+
safe_join(pieces)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def pp_media(url)
|
|
85
|
+
path = url.to_s.split(/[?#]/).first
|
|
86
|
+
if path.match?(VIDEO)
|
|
87
|
+
tag.video(src: url, controls: true, class: "pp-media")
|
|
88
|
+
elsif path.match?(AUDIO)
|
|
89
|
+
tag.audio(src: url, controls: true, class: "pp-audio")
|
|
90
|
+
elsif path.match?(IMAGE) || File.extname(path).empty?
|
|
91
|
+
# Many image URLs have no extension; if it turns out not to be one, the
|
|
92
|
+
# page swaps this for a link.
|
|
93
|
+
link_to(image_tag(url, alt: "", class: "pp-media", data: { fallback: File.basename(path) }),
|
|
94
|
+
url, target: "_blank", rel: "noopener")
|
|
95
|
+
else
|
|
96
|
+
link_to("📎 #{File.basename(path)}", url, class: "pp-file", target: "_blank", rel: "noopener")
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def pp_snippet(message)
|
|
101
|
+
return "" unless message
|
|
102
|
+
|
|
103
|
+
text = message["content"].to_s.strip
|
|
104
|
+
text = "Attachment" if text.empty? && (message["media_url"].present? || message["media_urls"].present?)
|
|
105
|
+
text = message.dig("app_card", "appName").to_s if text.empty? && message["app_card"]
|
|
106
|
+
truncate(text, length: 70)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def pp_tapback(reaction) = TAPBACKS[reaction] || reaction
|
|
110
|
+
|
|
111
|
+
def pp_preview_for(message)
|
|
112
|
+
found = LinkPreview.detect(message["content"])
|
|
113
|
+
preview = found[:url] && @db.previews[found[:url]]
|
|
114
|
+
card = preview && preview["error"].nil? && (preview["title"].present? || preview["image"].present?)
|
|
115
|
+
{ url: found[:url], reason: found[:reason], preview: preview, card: card ? preview : nil }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# --- what iMessage prints under a bubble ----------------------------------
|
|
119
|
+
|
|
120
|
+
# For a message the phone sent, the line under it: Delivered once the app
|
|
121
|
+
# accepted the webhook, Read once the app marked the thread read, and Not
|
|
122
|
+
# Delivered (with the reason) when the webhook failed.
|
|
123
|
+
def pp_receipt(message, conversation, last:)
|
|
124
|
+
hook = Array(message["webhooks"]).reverse.find { |w| w["type"] == "receive" }
|
|
125
|
+
return { text: "Sending…" } if hook.nil?
|
|
126
|
+
|
|
127
|
+
unless hook["status"].to_i.between?(200, 299)
|
|
128
|
+
reason = hook["error"] || "the webhook answered #{hook["status"]}"
|
|
129
|
+
return { text: "Not Delivered", failed: true, detail: reason }
|
|
130
|
+
end
|
|
131
|
+
return nil unless last
|
|
132
|
+
|
|
133
|
+
read = conversation["read_at"].to_s >= message["created_at"].to_s && message["service"] == "iMessage"
|
|
134
|
+
{ text: read ? "Read #{pp_clock(conversation["read_at"])}" : "Delivered" }
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def pp_webhook_outcome(hook)
|
|
138
|
+
hook["error"] || "HTTP #{hook["status"]}"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def pp_json(value)
|
|
142
|
+
JSON.pretty_generate(value)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
(() => {
|
|
3
|
+
const root = document.querySelector("[data-pp]");
|
|
4
|
+
if (!root) return;
|
|
5
|
+
|
|
6
|
+
const base = root.dataset.base;
|
|
7
|
+
const conversation = root.dataset.conversation;
|
|
8
|
+
const isGroup = root.dataset.group === "1";
|
|
9
|
+
const token = document.querySelector('meta[name="csrf-token"]')?.content;
|
|
10
|
+
const sidebar = document.getElementById("pp-sidebar");
|
|
11
|
+
const head = document.getElementById("pp-head");
|
|
12
|
+
const messages = document.getElementById("pp-messages");
|
|
13
|
+
const compose = document.querySelector("[data-compose]");
|
|
14
|
+
const persona = document.querySelector("[data-persona]");
|
|
15
|
+
const open = new Set();
|
|
16
|
+
let version = "";
|
|
17
|
+
|
|
18
|
+
const post = (path, body) => fetch(base + path, {
|
|
19
|
+
method: "POST",
|
|
20
|
+
body,
|
|
21
|
+
headers: { "X-CSRF-Token": token || "", "X-Requested-With": "XMLHttpRequest" },
|
|
22
|
+
});
|
|
23
|
+
const form = (fields) => {
|
|
24
|
+
const data = new FormData();
|
|
25
|
+
Object.entries(fields).forEach(([key, value]) => value != null && data.append(key, value));
|
|
26
|
+
return data;
|
|
27
|
+
};
|
|
28
|
+
const remember = (key, value) => { try { localStorage.setItem(key, value); } catch (_) {} };
|
|
29
|
+
const recall = (key) => { try { return localStorage.getItem(key); } catch (_) { return null; } };
|
|
30
|
+
const atBottom = () => messages.scrollHeight - messages.scrollTop - messages.clientHeight < 80;
|
|
31
|
+
const toBottom = () => { messages.scrollTop = messages.scrollHeight; };
|
|
32
|
+
|
|
33
|
+
// In a group, the bubbles on the right are whoever you are speaking as.
|
|
34
|
+
function decorate() {
|
|
35
|
+
if (!messages) return;
|
|
36
|
+
if (isGroup && persona) {
|
|
37
|
+
messages.querySelectorAll(".pp-row[data-from]").forEach((row) => {
|
|
38
|
+
row.classList.toggle("mine", row.dataset.from === persona.value);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
messages.querySelectorAll(".pp-row[data-handle]").forEach((row) => {
|
|
42
|
+
const shown = open.has(row.dataset.handle);
|
|
43
|
+
row.classList.toggle("open", shown);
|
|
44
|
+
row.querySelector(".pp-details").hidden = !shown;
|
|
45
|
+
});
|
|
46
|
+
const service = head?.querySelector("[data-service]")?.dataset.service;
|
|
47
|
+
if (compose && service) {
|
|
48
|
+
compose.classList.toggle("sms", service !== "iMessage");
|
|
49
|
+
compose.querySelector("textarea").placeholder = service === "iMessage" ? "iMessage" : "Text Message";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function poll() {
|
|
54
|
+
let response;
|
|
55
|
+
try {
|
|
56
|
+
response = await fetch(`${base}/state?conversation=${encodeURIComponent(conversation)}&version=${encodeURIComponent(version)}`,
|
|
57
|
+
{ headers: { Accept: "application/json" } });
|
|
58
|
+
} catch (_) { return; }
|
|
59
|
+
if (response.status !== 200) return;
|
|
60
|
+
|
|
61
|
+
const state = await response.json();
|
|
62
|
+
version = state.version;
|
|
63
|
+
if (state.gone) { window.location = base + "/"; return; }
|
|
64
|
+
if (sidebar.innerHTML !== state.sidebar) sidebar.innerHTML = state.sidebar;
|
|
65
|
+
if (head && state.head != null && head.dataset.html !== state.head && !head.contains(document.activeElement)) {
|
|
66
|
+
head.innerHTML = state.head;
|
|
67
|
+
head.dataset.html = state.head;
|
|
68
|
+
decorate();
|
|
69
|
+
}
|
|
70
|
+
if (messages && state.messages != null && messages.dataset.html !== state.messages) {
|
|
71
|
+
const stick = atBottom();
|
|
72
|
+
messages.innerHTML = state.messages;
|
|
73
|
+
messages.dataset.html = state.messages;
|
|
74
|
+
decorate();
|
|
75
|
+
if (stick) toBottom();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// --- sending ---------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
if (compose) {
|
|
82
|
+
const text = compose.querySelector("textarea");
|
|
83
|
+
const file = compose.querySelector('input[type="file"]');
|
|
84
|
+
const attached = compose.querySelector("[data-attached]");
|
|
85
|
+
let typingAt = 0;
|
|
86
|
+
|
|
87
|
+
const grow = () => { text.style.height = "auto"; text.style.height = `${text.scrollHeight}px`; };
|
|
88
|
+
const showFile = () => {
|
|
89
|
+
attached.hidden = !file.files.length;
|
|
90
|
+
attached.textContent = file.files.length ? `📎 ${file.files[0].name} ✕` : "";
|
|
91
|
+
};
|
|
92
|
+
const typing = (on) => {
|
|
93
|
+
if (isGroup) return;
|
|
94
|
+
if (on && Date.now() - typingAt < 4000) return;
|
|
95
|
+
typingAt = on ? Date.now() : 0;
|
|
96
|
+
post(`/conversations/${conversation}/typing`, form({ is_typing: on }));
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const send = async () => {
|
|
100
|
+
if (!text.value.trim() && !file.files.length) return;
|
|
101
|
+
const data = new FormData(compose);
|
|
102
|
+
text.value = ""; file.value = ""; showFile(); grow(); typingAt = 0;
|
|
103
|
+
await post(`/conversations/${conversation}/messages`, data);
|
|
104
|
+
await poll();
|
|
105
|
+
toBottom();
|
|
106
|
+
text.focus();
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
compose.addEventListener("submit", (event) => { event.preventDefault(); send(); });
|
|
110
|
+
text.addEventListener("keydown", (event) => {
|
|
111
|
+
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) { event.preventDefault(); send(); }
|
|
112
|
+
});
|
|
113
|
+
text.addEventListener("input", () => { grow(); typing(text.value.length > 0); });
|
|
114
|
+
file.addEventListener("change", showFile);
|
|
115
|
+
attached.addEventListener("click", () => { file.value = ""; showFile(); });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (persona) {
|
|
119
|
+
const key = `pocket_phone:as:${conversation}`;
|
|
120
|
+
const saved = recall(key);
|
|
121
|
+
if (saved && [...persona.options].some((option) => option.value === saved)) persona.value = saved;
|
|
122
|
+
persona.addEventListener("change", () => { remember(key, persona.value); decorate(); });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// --- everything clickable --------------------------------------------------
|
|
126
|
+
|
|
127
|
+
document.addEventListener("click", async (event) => {
|
|
128
|
+
const target = event.target.closest("[data-details], [data-tapback], [data-redeliver], [data-open-dialog], [data-close-dialog]");
|
|
129
|
+
if (!target) return;
|
|
130
|
+
const row = target.closest(".pp-row");
|
|
131
|
+
const dialog = document.querySelector("[data-dialog]");
|
|
132
|
+
|
|
133
|
+
if (target.matches("[data-open-dialog]")) { dialog.showModal(); return; }
|
|
134
|
+
if (target.matches("[data-close-dialog]")) { dialog.close(); return; }
|
|
135
|
+
if (target.matches("[data-details]")) {
|
|
136
|
+
open.has(row.dataset.handle) ? open.delete(row.dataset.handle) : open.add(row.dataset.handle);
|
|
137
|
+
decorate();
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (target.matches("[data-tapback]")) {
|
|
141
|
+
await post(`/messages/${row.dataset.handle}/reactions`,
|
|
142
|
+
form({ reaction: target.dataset.tapback, as: persona ? persona.value : null }));
|
|
143
|
+
} else if (target.matches("[data-redeliver]")) {
|
|
144
|
+
await post(`/messages/${row.dataset.handle}/redeliver`, form({}));
|
|
145
|
+
}
|
|
146
|
+
poll();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
document.addEventListener("submit", (event) => {
|
|
150
|
+
const target = event.target;
|
|
151
|
+
if (target.dataset.confirm && !window.confirm(target.dataset.confirm)) { event.preventDefault(); return; }
|
|
152
|
+
if (!target.dataset.remote) return;
|
|
153
|
+
event.preventDefault();
|
|
154
|
+
post(new URL(target.action).pathname.slice(base.length), new FormData(target)).then(poll);
|
|
155
|
+
});
|
|
156
|
+
document.addEventListener("change", (event) => {
|
|
157
|
+
if (!event.target.matches("[data-autosubmit]")) return;
|
|
158
|
+
event.target.form.requestSubmit();
|
|
159
|
+
event.target.blur();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// An attachment with no extension is tried as an image first; when it is
|
|
163
|
+
// not one, show it as a file instead.
|
|
164
|
+
document.addEventListener("error", (event) => {
|
|
165
|
+
const image = event.target;
|
|
166
|
+
if (!(image instanceof HTMLImageElement) || !image.dataset.fallback || !image.parentElement) return;
|
|
167
|
+
image.parentElement.className = "pp-file";
|
|
168
|
+
image.parentElement.textContent = `📎 ${image.dataset.fallback}`;
|
|
169
|
+
}, true);
|
|
170
|
+
if (messages) {
|
|
171
|
+
messages.addEventListener("load", () => { if (atBottom() || !messages.dataset.settled) toBottom(); }, true);
|
|
172
|
+
setTimeout(() => { messages.dataset.settled = "1"; }, 1500);
|
|
173
|
+
decorate();
|
|
174
|
+
toBottom();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
document.querySelector("[data-dialog]")?.addEventListener("click", (event) => {
|
|
178
|
+
if (event.target === event.currentTarget) event.currentTarget.close();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
setInterval(poll, 1000);
|
|
182
|
+
})();
|
|
183
|
+
</script>
|