From 6820149d3bf37d27ac6887e476e6d75e15121451 Mon Sep 17 00:00:00 2001
From: Aditya Patil ', "maps": "apple"},
+ )
+ doc = frappe.get_doc("Raven Message", msg.name)
+ self.assertEqual(doc.text.count("hi
where?
" + )[0] + kwargs = dict( + channel_id=self.channel.name, + client_id="loc-retry", + is_reply=True, + linked_message=parent.name, + send_silently=True, + location={"latitude": 1, "longitude": 2}, + ) + first = send_message_with_attachments(**kwargs) + again = send_message_with_attachments(**kwargs) + self.assertEqual([m.name for m in first], [m.name for m in again]) + self.assertEqual( + frappe.db.get_value("Raven Message", first[0].name, "linked_message"), parent.name + ) + + def test_notification_text(self): + [a] = send_message_with_attachments( + channel_id=self.channel.name, location={"latitude": 1, "longitude": 2, "name": "MG Road"} + ) + [b] = send_message_with_attachments( + channel_id=self.channel.name, location={"latitude": 1, "longitude": 2} + ) + self.assertEqual( + frappe.get_doc("Raven Message", a.name).get_notification_message_content(), + "π Shared a location - MG Road", + ) + self.assertEqual( + frappe.get_doc("Raven Message", b.name).get_notification_message_content(), + "π Shared a location", + ) + + def test_json_attached_for_location_rows(self): + [loc] = send_message_with_attachments( + channel_id=self.channel.name, location={"latitude": 1, "longitude": 2} + ) + rows = attach_json_payloads([frappe._dict(name=loc.name, message_type="Location")]) + self.assertEqual(frappe.parse_json(rows[0]["json"])["latitude"], 1.0) + + def test_reply_snapshot_carries_location_json(self): + [loc] = send_message_with_attachments( + channel_id=self.channel.name, + location={"latitude": 1, "longitude": 2, "name": "Cafe"}, + ) + reply = send_message_with_attachments( + channel_id=self.channel.name, + content="ok
", + is_reply=True, + linked_message=loc.name, + )[0] + snapshot = frappe.parse_json( + frappe.db.get_value("Raven Message", reply.name, "replied_message_details") + ) + self.assertEqual(snapshot["message_type"], "Location") + self.assertEqual(frappe.parse_json(snapshot["json"])["name"], "Cafe") + + def test_forwarded_location_reply_keeps_payload(self): + parent = send_message_with_attachments( + channel_id=self.channel.name, content="where?
" + )[0] + [loc] = send_message_with_attachments( + channel_id=self.channel.name, + is_reply=True, + linked_message=parent.name, + location={"latitude": 1, "longitude": 2}, + ) + source = frappe.get_doc("Raven Message", loc.name) + [payload] = _forward_payloads(loc.name) + self.assertEqual(payload["message_type"], "Location") + self.assertEqual(payload["text"], source.text) + self.assertEqual(frappe.parse_json(payload["json"])["latitude"], 1.0) + self.assertNotIn("is_reply", payload) + + def test_native_boot_advertises_location(self): + from raven.api.native import boot + + self.assertIn("location_messages", boot()["raven_features"]) + + def test_location_text_not_editable(self): + [msg] = send_message_with_attachments( + channel_id=self.channel.name, location={"latitude": 1, "longitude": 2} + ) + doc = frappe.get_doc("Raven Message", msg.name) + doc.text = "edited
" + with self.assertRaises(frappe.ValidationError): + doc.save() diff --git a/raven/tests/test_voice_notes.py b/raven/tests/test_voice_notes.py index 2746e96f6..b901b046a 100644 --- a/raven/tests/test_voice_notes.py +++ b/raven/tests/test_voice_notes.py @@ -183,8 +183,8 @@ def test_forwarded_voice_note_keeps_payload(self): self.assertEqual(frappe.parse_json(copy.json), {"duration": 7.25, "waveform": [0, 50, 100]}) self.assertEqual(copy.file, self.audio_url) - def test_attach_voice_payloads_fills_only_voice_rows(self): - from raven.api.raven_message import attach_voice_payloads + def test_attach_json_payloads_fills_only_voice_rows(self): + from raven.api.raven_message import attach_json_payloads voice = self._send(self.audio_url, {"duration": 7.25, "waveform": [1, 2]}) text = send_message_with_attachments(channel_id=self.channel.name, content="hi
")[0] @@ -193,7 +193,7 @@ def test_attach_voice_payloads_fills_only_voice_rows(self): filters={"name": ["in", [voice.name, text.name]]}, fields=["name", "message_type"], ) - by_name = {row.name: row for row in attach_voice_payloads(rows)} + by_name = {row.name: row for row in attach_json_payloads(rows)} self.assertEqual( frappe.parse_json(by_name[voice.name].json), {"duration": 7.25, "waveform": [1, 2]} ) @@ -209,11 +209,11 @@ def test_saved_messages_carry_the_voice_payload(self): saved = next(row for row in get_saved_messages() if row.name == voice.name) self.assertEqual(frappe.parse_json(saved.json), {"duration": 7.25, "waveform": [1, 2]}) - def test_attach_voice_payloads_reads_other_users_notes(self): - from raven.api.raven_message import attach_voice_payloads + def test_attach_json_payloads_reads_other_users_notes(self): + from raven.api.raven_message import attach_json_payloads voice = self._send(self.audio_url, {"duration": 7.25, "waveform": [1, 2]}) # Another member reads it: message list permissions are owner-only, so the helper must not apply them. frappe.set_user("test@example.com") - rows = attach_voice_payloads([frappe._dict(name=voice.name, message_type="Voice")]) + rows = attach_json_payloads([frappe._dict(name=voice.name, message_type="Voice")]) self.assertEqual(frappe.parse_json(rows[0].json), {"duration": 7.25, "waveform": [1, 2]}) From a2bc9abbb52b4be062d5f325e23dbb4736649717 Mon Sep 17 00:00:00 2001 From: Aditya Patilwhere?
" - )[0] + parent = send_message_with_attachments(channel_id=self.channel.name, content="where?
")[0] kwargs = dict( channel_id=self.channel.name, client_id="loc-retry", @@ -196,18 +209,11 @@ def test_reply_silent_and_idempotent(self): ) def test_notification_text(self): - [a] = send_message_with_attachments( + [msg] = send_message_with_attachments( channel_id=self.channel.name, location={"latitude": 1, "longitude": 2, "name": "MG Road"} ) - [b] = send_message_with_attachments( - channel_id=self.channel.name, location={"latitude": 1, "longitude": 2} - ) - self.assertEqual( - frappe.get_doc("Raven Message", a.name).get_notification_message_content(), - "π Shared a location - MG Road", - ) self.assertEqual( - frappe.get_doc("Raven Message", b.name).get_notification_message_content(), + frappe.get_doc("Raven Message", msg.name).get_notification_message_content(), "π Shared a location", ) @@ -218,10 +224,10 @@ def test_json_attached_for_location_rows(self): rows = attach_json_payloads([frappe._dict(name=loc.name, message_type="Location")]) self.assertEqual(frappe.parse_json(rows[0]["json"])["latitude"], 1.0) - def test_reply_snapshot_carries_location_json(self): + def test_reply_snapshot_has_no_location_json(self): [loc] = send_message_with_attachments( channel_id=self.channel.name, - location={"latitude": 1, "longitude": 2, "name": "Cafe"}, + location={"latitude": 1, "longitude": 2}, ) reply = send_message_with_attachments( channel_id=self.channel.name, @@ -233,12 +239,36 @@ def test_reply_snapshot_carries_location_json(self): frappe.db.get_value("Raven Message", reply.name, "replied_message_details") ) self.assertEqual(snapshot["message_type"], "Location") - self.assertEqual(frappe.parse_json(snapshot["json"])["name"], "Cafe") + self.assertNotIn("json", snapshot) + + def _enqueued_link_previews(self, enqueue): + return [ + call + for call in enqueue.call_args_list + if call.args and call.args[0] == "raven.links.process_message_links" + ] + + def test_location_skips_link_preview(self): + with patch( + "raven.raven_messaging.doctype.raven_message.raven_message.frappe.enqueue" + ) as enqueue: + send_message_with_attachments( + channel_id=self.channel.name, location={"latitude": 1, "longitude": 2} + ) + self.assertEqual(self._enqueued_link_previews(enqueue), []) + + def test_text_with_link_queues_link_preview(self): + with patch( + "raven.raven_messaging.doctype.raven_message.raven_message.frappe.enqueue" + ) as enqueue: + send_message_with_attachments( + channel_id=self.channel.name, + content='', + ) + self.assertEqual(len(self._enqueued_link_previews(enqueue)), 1) def test_forwarded_location_reply_keeps_payload(self): - parent = send_message_with_attachments( - channel_id=self.channel.name, content="where?
" - )[0] + parent = send_message_with_attachments(channel_id=self.channel.name, content="where?
")[0] [loc] = send_message_with_attachments( channel_id=self.channel.name, is_reply=True, @@ -252,6 +282,56 @@ def test_forwarded_location_reply_keeps_payload(self): self.assertEqual(frappe.parse_json(payload["json"])["latitude"], 1.0) self.assertNotIn("is_reply", payload) + def _upload_image(self, name): + return frappe.get_doc( + { + "doctype": "File", + "file_name": name, + "content": "image-bytes", + "is_private": 1, + "attached_to_doctype": "Raven Message", + } + ).insert(ignore_permissions=True) + + def _upload_file(self, name): + return frappe.get_doc( + { + "doctype": "File", + "file_name": name, + "content": MINIMAL_PDF, + "is_private": 1, + "attached_to_doctype": "Raven Message", + } + ).insert(ignore_permissions=True) + + def test_location_with_snapshot(self): + snapshot = self._upload_image("location-1-2.webp") + [msg] = send_message_with_attachments( + channel_id=self.channel.name, + location={"latitude": 1, "longitude": 2}, + files=[{"file_url": snapshot.file_url, "file_size": 1234}], + ) + doc = frappe.get_doc("Raven Message", msg.name) + self.assertEqual( + (doc.message_type, doc.file, doc.file_size), ("Location", snapshot.file_url, 1234) + ) + files = get_all_files_shared_in_channel(self.channel.name) + self.assertNotIn(msg.name, [f.get("name") or f.get("message_id") for f in files]) + + def test_location_snapshot_rules(self): + img = self._upload_image("a.webp") + pdf = self._upload_file("a.pdf") + for files in ( + [{"file_url": img.file_url, "file_size": 1}] * 2, + [{"file_url": pdf.file_url, "file_size": 1}], + ): + with self.assertRaises(frappe.ValidationError): + send_message_with_attachments( + channel_id=self.channel.name, + location={"latitude": 1, "longitude": 2}, + files=files, + ) + def test_native_boot_advertises_location(self): from raven.api.native import boot diff --git a/yarn.lock b/yarn.lock index a2064fa2f..7b10363cf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -860,6 +860,11 @@ resolved "https://registry.yarnpkg.com/@capacitor/android/-/android-8.5.0.tgz#1fa7c7eb00b6600df1f346b6548869670e6cd0d3" integrity sha512-Rb3prJeQiTp0pQhSSOReJuG9VgibOGMG4ECs+UhMwr6TCCHZ3NCZO811bDA0HxD0xrT/gCrJAsY5yfEATu84JQ== +"@capacitor/app-launcher@8.0.1": + version "8.0.1" + resolved "https://registry.yarnpkg.com/@capacitor/app-launcher/-/app-launcher-8.0.1.tgz#63f5ed3c0c8745c936d5af4698f40119ae5a0618" + integrity sha512-23D8zi74sn7kxvISix8qYwgqdxGJN+4NImcNGvHen98LB1zb4eZfkJvFvp7pwntxGw4OjIE7yuf4wbzZxQHpog== + "@capacitor/app@8.1.1": version "8.1.1" resolved "https://registry.yarnpkg.com/@capacitor/app/-/app-8.1.1.tgz#af00649d0c7799ec83469b1c4ada921f9933a77e" @@ -926,6 +931,13 @@ dependencies: "@capacitor/synapse" "^1.0.4" +"@capacitor/geolocation@8.2.4": + version "8.2.4" + resolved "https://registry.yarnpkg.com/@capacitor/geolocation/-/geolocation-8.2.4.tgz#a2e8cb1175d18504d678114a4eba1cf5c0a35750" + integrity sha512-FDcUiPQc2AXSTAV61QSDPXml11guKBx5HPEkdWvUqw7kMqrc2Ucp0bsWngeLyRs/01a5aWBI0hEuvetuF3PsrQ== + dependencies: + "@capacitor/synapse" "^1.0.4" + "@capacitor/haptics@8.0.2": version "8.0.2" resolved "https://registry.yarnpkg.com/@capacitor/haptics/-/haptics-8.0.2.tgz#821d2687b81ff8855327273314d0d146476f069f" @@ -3531,6 +3543,11 @@ dependencies: "@types/node" "*" +"@types/geojson@*": + version "7946.0.16" + resolved "https://registry.yarnpkg.com/@types/geojson/-/geojson-7946.0.16.tgz#8ebe53d69efada7044454e3305c19017d97ced2a" + integrity sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg== + "@types/hast@^3.0.0": version "3.0.4" resolved "https://registry.yarnpkg.com/@types/hast/-/hast-3.0.4.tgz#1d6b39993b82cea6ad783945b0508c25903e15aa" @@ -3548,6 +3565,13 @@ resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== +"@types/leaflet@^1.9.22": + version "1.9.22" + resolved "https://registry.yarnpkg.com/@types/leaflet/-/leaflet-1.9.22.tgz#d46d501e5bcdf5a99374cf113ab33562cb887783" + integrity sha512-h3lhECYEKDasG7LFHu+GiHqAvsgLuQvlJvVZzJDGONo3sEL+wUOqSFLnwkZlK0qVxnxbuGFW8iBlJNYs5wgndA== + dependencies: + "@types/geojson" "*" + "@types/mdast@^4.0.0": version "4.0.4" resolved "https://registry.yarnpkg.com/@types/mdast/-/mdast-4.0.4.tgz#7ccf72edd2f1aa7dd3437e180c64373585804dd6" @@ -5861,6 +5885,11 @@ kleur@^4.1.5: resolved "https://registry.yarnpkg.com/kleur/-/kleur-4.1.5.tgz#95106101795f7050c6c650f350c683febddb1780" integrity sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ== +leaflet@^1.9.4: + version "1.9.4" + resolved "https://registry.yarnpkg.com/leaflet/-/leaflet-1.9.4.tgz#23fae724e282fa25745aff82ca4d394748db7d8d" + integrity sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA== + leven@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/leven/-/leven-3.1.0.tgz#77891de834064cccba82ae7842bb6b14a13ed7f2" From fec3dc118cb9d36d115d1f8e6db56aca5cdc76b4 Mon Sep 17 00:00:00 2001 From: Aditya Patilπ Location
") + }) }) diff --git a/apps/web/src/components/common/MessageResultBlock/searchResultToMessage.ts b/apps/web/src/components/common/MessageResultBlock/searchResultToMessage.ts index 88cc40681..31fce6fa9 100644 --- a/apps/web/src/components/common/MessageResultBlock/searchResultToMessage.ts +++ b/apps/web/src/components/common/MessageResultBlock/searchResultToMessage.ts @@ -47,6 +47,9 @@ export function searchResultToMessage(r: SearchResult): Message { if (messageType === 'Voice') { return { ...base, message_type: 'Text', text: `π€ ${_("Voice note")}
` } } + if (messageType === 'Location') { + return { ...base, message_type: 'Text', text: `π ${_("Location")}
` } + } if (messageType === 'Poll') { return { ...base, diff --git a/apps/web/src/components/features/message/actions/dialogs/MessagePreview.tsx b/apps/web/src/components/features/message/actions/dialogs/MessagePreview.tsx index de3ab7c6a..7150106db 100644 --- a/apps/web/src/components/features/message/actions/dialogs/MessagePreview.tsx +++ b/apps/web/src/components/features/message/actions/dialogs/MessagePreview.tsx @@ -1,4 +1,4 @@ -import { BarChart3Icon, File, FileText, Film, MessageSquareText, Mic, Music, type LucideIcon } from "lucide-react" +import { BarChart3Icon, File, FileText, Film, MapPin, MessageSquareText, Mic, Music, type LucideIcon } from "lucide-react" import { getAttachmentKind, type AttachmentKind } from "@utils/attachmentPreview" import { formatBytes, getFileExtension } from "@lib/file" import { getFileName } from "@raven/lib/utils/operations" @@ -68,6 +68,18 @@ export const MessagePreview = ({ message }: { message: Message }) => { ) } + // A location's snapshot is not a downloadable attachment, so it gets its own row. + if (message.message_type === "Location") { + return ( + <> +{_("Location")}
+ > + ) + } + if (file) { const extension = getFileExtension(file) const kind = getAttachmentKind(file) diff --git a/apps/web/src/components/features/message/actions/editTarget.ts b/apps/web/src/components/features/message/actions/editTarget.ts index 355f1db24..15f3cdff8 100644 --- a/apps/web/src/components/features/message/actions/editTarget.ts +++ b/apps/web/src/components/features/message/actions/editTarget.ts @@ -9,7 +9,7 @@ import type { Message } from "@raven/types/common/Message" * file messages aren't editable. */ export const resolveEditTarget = (message: Message): Message | null => { - if (message.message_type === "Poll") return null + if (message.message_type === "Poll" || message.message_type === "Location") return null if (message.message_batch_id) { const members = channelMessagesStore.batchMembers(message.channel_id, message.message_batch_id) return members.find((member) => member.text) ?? null diff --git a/apps/web/src/components/features/message/actions/useMessageActions.tsx b/apps/web/src/components/features/message/actions/useMessageActions.tsx index dd9938258..f6ba58b4a 100644 --- a/apps/web/src/components/features/message/actions/useMessageActions.tsx +++ b/apps/web/src/components/features/message/actions/useMessageActions.tsx @@ -247,7 +247,8 @@ export const useMessageActions = ( onSelect: () => setDialog({ type: "forward", message }), }) } - if (message.text || message.content) { + // A location's text is the fallback link β nothing worth copying. + if (message.message_type !== "Location" && (message.text || message.content)) { clipboard.push({ id: "copy", label: _("Copy"), diff --git a/apps/web/src/components/features/message/renderers/LocationCard.tsx b/apps/web/src/components/features/message/renderers/LocationCard.tsx new file mode 100644 index 000000000..741cc2c8b --- /dev/null +++ b/apps/web/src/components/features/message/renderers/LocationCard.tsx @@ -0,0 +1,63 @@ +import { forwardRef, useState, type ComponentPropsWithoutRef, type Ref } from "react" +import { ExternalLink, MapPin } from "lucide-react" +import type { LocationMeta } from "@lib/location" +import { cn } from "@lib/utils" +import _ from "@lib/translate" + +const cardClass = + "flex h-[76px] w-full max-w-[420px] items-center gap-3 rounded-xl border border-outline-gray-2 bg-surface-base p-1.5" + +type LocationCardProps = { + meta: LocationMeta | null + snapshotUrl?: string + onOpen?: () => void +} & Omitπ€ ${_("Voice note")}
` } } + if (messageType === "Location") { + return { ...base, message_type: "Text", text: `π ${_("Location")}
` } + } if (messageType === "File") { const fileName = escapeHtml((r.message_file ?? "").split("/").pop() ?? "") return { ...base, message_type: "Text", text: r.message_text || `π ${fileName || _("Sent a file")}
` } @@ -118,6 +121,7 @@ function reminderRowToMessage(r: ReminderRow): Message { const messagePreview = (r: ReminderRow): string => { if (r.message_type === "Image") return `π· ${_("Sent a photo")}` if (r.message_type === "Voice") return `π€ ${_("Voice note")}` + if (r.message_type === "Location") return `π ${_("Location")}` if (r.message_type === "File") return `π ${r.message_content || (r.message_file ?? "").split("/").pop() || _("Sent a file")}` // A poll's content is its question on the first line, then the options. if (r.message_type === "Poll") return `π ${r.message_content?.split("\n")[0]?.trim() || _("Poll")}` diff --git a/apps/web/src/components/features/saved-messages/savedRowToMessage.test.ts b/apps/web/src/components/features/saved-messages/savedRowToMessage.test.ts index ae389cabb..94fd7bc89 100644 --- a/apps/web/src/components/features/saved-messages/savedRowToMessage.test.ts +++ b/apps/web/src/components/features/saved-messages/savedRowToMessage.test.ts @@ -16,4 +16,25 @@ describe("savedRowToMessage", () => { it("keeps a voice note's file and payload", () => { expect(savedRowToMessage(row)).toMatchObject({ message_type: "Voice", file: "/private/files/v.m4a", json: '{"duration":7,"waveform":[1]}' }) }) + + it("keeps a location's payload and snapshot", () => { + const message = savedRowToMessage({ + ...row, + message_type: "Location", + text: "link
", + file: "/private/files/location.webp", + json: '{"latitude":1,"longitude":2}', + }) + expect(message).toMatchObject({ + message_type: "Location", + text: "link
", + file: "/private/files/location.webp", + json: '{"latitude":1,"longitude":2}', + }) + }) + + it("omits the file for a location without a snapshot", () => { + const message = savedRowToMessage({ ...row, message_type: "Location", file: undefined, json: '{"latitude":1,"longitude":2}' }) + expect("file" in message).toBe(false) + }) }) diff --git a/apps/web/src/components/features/saved-messages/savedRowToMessage.ts b/apps/web/src/components/features/saved-messages/savedRowToMessage.ts index e00002b70..d8957bb5f 100644 --- a/apps/web/src/components/features/saved-messages/savedRowToMessage.ts +++ b/apps/web/src/components/features/saved-messages/savedRowToMessage.ts @@ -11,7 +11,7 @@ export type SavedMessageRow = { content?: string channel_id: string file?: string - /** Voice notes only: `{ duration, waveform }`. */ + /** Voice notes only: `{ duration, waveform }`. Locations: `{ latitude, longitude }`. */ json?: string | Recordlink
", json: '{"latitude":1,"longitude":2}' } as unknown as Message + expect("json" in JSON.parse(buildReplySnapshot(location)!)).toBe(false) + }) }) diff --git a/packages/types/common/Message.ts b/packages/types/common/Message.ts index e496762a2..18f6b3e60 100644 --- a/packages/types/common/Message.ts +++ b/packages/types/common/Message.ts @@ -63,6 +63,9 @@ export interface VoiceMessage extends BaseMessage { export interface LocationMessage extends BaseMessage { text: string, message_type: 'Location' + /** Snapshot image attached at send. */ + file?: string, + file_size?: number, } export interface ImageMessage extends BaseMessage { From c4d0ddc3362d2044ca071fa9ea97ef336de09223 Mon Sep 17 00:00:00 2001 From: Aditya Patil{title}
+{subtitle}
+{message}
+ {onRetry && ( + + )} +[^<]+<\/a><\/p>$/)
+ })
+
+ it("escapes the URL in both the href and the link text", () => {
+ const escaped = escapeHtml(senderMapsUrl(location))
+ const html = locationFallbackHtml(location)
+
+ expect(escaped).toContain("&")
+ expect(html).toContain(`href="${escaped}"`)
+ expect(html).toContain(`>${escaped}`)
+ })
+})
diff --git a/apps/web/src/components/features/ChatInput/location/pickerPanel.test.ts b/apps/web/src/components/features/ChatInput/location/pickerPanel.test.ts
new file mode 100644
index 000000000..982a2d5f8
--- /dev/null
+++ b/apps/web/src/components/features/ChatInput/location/pickerPanel.test.ts
@@ -0,0 +1,41 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+import type { PositionFix } from "../../../../native/geolocation"
+import { centreOf, isPanKey, pickerPanel } from "./pickerPanel"
+
+// _() reads window.frappe; node has no window.
+beforeEach(() => vi.stubGlobal("window", {}))
+afterEach(() => vi.unstubAllGlobals())
+
+const fix: PositionFix = { latitude: 12.9756, longitude: 77.6066, accuracy: 15.4 }
+
+describe("pickerPanel", () => {
+ it("offers the current location until the map is dragged", () => {
+ expect(pickerPanel({ centre: fix, fix, dragged: false })).toEqual({
+ title: "Send your current location",
+ subtitle: "Accurate to 15 m",
+ })
+ })
+ it("offers the pin after a drag", () => {
+ expect(pickerPanel({ centre: { latitude: 12.98, longitude: 77.61 }, fix, dragged: true })).toEqual({
+ title: "Send this location",
+ subtitle: "12.98000, 77.61000",
+ })
+ })
+})
+
+describe("isPanKey", () => {
+ it("recognises the arrow keys Leaflet pans with", () => {
+ for (const key of ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"]) expect(isPanKey(key)).toBe(true)
+ for (const key of ["a", "Enter", "Escape", "+", "-"]) expect(isPanKey(key)).toBe(false)
+ })
+})
+
+describe("centreOf", () => {
+ it("keeps an in-range longitude unchanged", () => {
+ expect(centreOf({ lat: 1, lng: 2 })).toEqual({ latitude: 1, longitude: 2 })
+ })
+ it("wraps a longitude past the antimeridian", () => {
+ expect(centreOf({ lat: 1, lng: 185 })).toEqual({ latitude: 1, longitude: -175 })
+ expect(centreOf({ lat: 1, lng: -190 })).toEqual({ latitude: 1, longitude: 170 })
+ })
+})
diff --git a/apps/web/src/components/features/ChatInput/location/pickerPanel.ts b/apps/web/src/components/features/ChatInput/location/pickerPanel.ts
new file mode 100644
index 000000000..50a666802
--- /dev/null
+++ b/apps/web/src/components/features/ChatInput/location/pickerPanel.ts
@@ -0,0 +1,35 @@
+import { formatCoordinates, type LocationMeta } from "@lib/location"
+import _ from "@lib/translate"
+import type { PositionFix } from "../../../../native/geolocation"
+
+export type PickerPanel = { title: string; subtitle: string }
+
+const wrapLongitude = (longitude: number): number => ((((longitude + 180) % 360) + 360) % 360) - 180
+
+const PAN_KEYS = new Set(["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"])
+
+/** Leaflet pans the map by arrows; those moves must count as a drag. */
+export const isPanKey = (key: string): boolean => PAN_KEYS.has(key)
+
+/** Leaflet's unwrapped centre (e.g. 185) as coordinates the server accepts. */
+export const centreOf = (latLng: { lat: number; lng: number }): LocationMeta => ({
+ latitude: latLng.lat,
+ longitude: wrapLongitude(latLng.lng),
+})
+
+/** The panel above the footer: the fix until the map moves, the pin's coordinates after. */
+export const pickerPanel = ({
+ centre,
+ fix,
+ dragged,
+}: {
+ centre: LocationMeta
+ fix: PositionFix
+ dragged: boolean
+}): PickerPanel => {
+ if (dragged) return { title: _("Send this location"), subtitle: formatCoordinates(centre) }
+ return {
+ title: _("Send your current location"),
+ subtitle: _("Accurate to {0} m", [String(Math.round(fix.accuracy))]),
+ }
+}
diff --git a/apps/web/src/components/features/ChatInput/location/pinShape.ts b/apps/web/src/components/features/ChatInput/location/pinShape.ts
new file mode 100644
index 000000000..f3aef290e
--- /dev/null
+++ b/apps/web/src/components/features/ChatInput/location/pinShape.ts
@@ -0,0 +1,7 @@
+export const PIN_VIEWBOX = "0 0 28 36"
+export const PIN_PATH = "M14 0C6.3 0 0 6.2 0 13.9 0 24.3 14 36 14 36s14-11.7 14-22.1C28 6.2 21.7 0 14 0z"
+export const PIN_WIDTH = 28
+export const PIN_HEIGHT = 36
+
+// Mirrors the light-mode `text-ink-red-6`: the snapshot is a saved image.
+export const PIN_COLOR = "#e03434"
diff --git a/apps/web/src/components/features/ChatInput/location/snapshot.test.ts b/apps/web/src/components/features/ChatInput/location/snapshot.test.ts
new file mode 100644
index 000000000..94bb6d84d
--- /dev/null
+++ b/apps/web/src/components/features/ChatInput/location/snapshot.test.ts
@@ -0,0 +1,100 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+import {
+ SNAPSHOT_SIZE,
+ drawSnapshot,
+ snapshotDrawPlan,
+ snapshotFileName,
+ withTimeout,
+} from "./snapshot"
+
+const zoomOf = (url: string): number => Number(url.split("/")[3])
+
+afterEach(() => vi.useRealTimers())
+
+const META = { latitude: 12.9756, longitude: 77.6066 }
+
+describe("snapshotDrawPlan", () => {
+ it("centres the point on the 128 px canvas", () => {
+ const { tiles, pin } = snapshotDrawPlan(META, 17)
+ expect(SNAPSHOT_SIZE).toBe(128)
+ expect(pin).toEqual({ x: 64, y: 64 })
+ expect(tiles.every((tile) => tile.size === 256)).toBe(true)
+ expect(Math.min(...tiles.map((tile) => tile.left))).toBeLessThanOrEqual(0)
+ expect(Math.max(...tiles.map((tile) => tile.left + tile.size))).toBeGreaterThanOrEqual(128)
+ })
+
+ it("fetches tiles at the map's zoom", () => {
+ const { tiles } = snapshotDrawPlan(META, 17)
+ expect(new Set(tiles.map((tile) => zoomOf(tile.url)))).toEqual(new Set([17]))
+ })
+
+ it.each([
+ [12, 15],
+ [20, 18],
+ ])("clamps zoom %i to %i", (zoom, clamped) => {
+ const { tiles } = snapshotDrawPlan(META, zoom)
+ expect(new Set(tiles.map((tile) => zoomOf(tile.url)))).toEqual(new Set([clamped]))
+ })
+})
+
+describe("snapshotFileName", () => {
+ it("uses six decimals for the coordinates", () => {
+ const name = snapshotFileName({ latitude: 12.975612, longitude: 77.606634 }, "image/webp")
+ expect(name).toBe("location-12.975612-77.606634.webp")
+ })
+
+ it("uses png when the browser encodes png", () => {
+ expect(snapshotFileName({ latitude: 1, longitude: 2 }, "image/png")).toBe(
+ "location-1.000000-2.000000.png",
+ )
+ })
+})
+
+describe("withTimeout", () => {
+ it("times out a slow snapshot", async () => {
+ vi.useFakeTimers()
+ const result = withTimeout(new Promise(() => {}), 5000)
+ vi.advanceTimersByTime(5000)
+ await expect(result).resolves.toBeNull()
+ })
+
+ it("passes a fast result through", async () => {
+ await expect(withTimeout(Promise.resolve("ok"), 5000)).resolves.toBe("ok")
+ })
+})
+
+describe("drawSnapshot", () => {
+ beforeEach(() => vi.stubGlobal("Path2D", class {}))
+ afterEach(() => vi.unstubAllGlobals())
+
+ const contextOf = () => ({
+ drawImage: vi.fn(),
+ save: vi.fn(),
+ restore: vi.fn(),
+ translate: vi.fn(),
+ beginPath: vi.fn(),
+ arc: vi.fn(),
+ fill: vi.fn(),
+ fillRect: vi.fn(),
+ fillText: vi.fn(),
+ measureText: vi.fn(() => ({ width: 0 })),
+ fillStyle: "",
+ font: "",
+ })
+
+ it("paints the tiles and the pin, with no text or chip", () => {
+ const context = contextOf()
+ const plan = snapshotDrawPlan(META, 17)
+ drawSnapshot(
+ context as unknown as CanvasRenderingContext2D,
+ plan,
+ plan.tiles.map(() => ({}) as HTMLImageElement),
+ )
+
+ expect(context.drawImage).toHaveBeenCalledTimes(plan.tiles.length)
+ expect(context.fill).toHaveBeenCalledTimes(2)
+ expect(context.fillText).not.toHaveBeenCalled()
+ expect(context.fillRect).not.toHaveBeenCalled()
+ expect(context.measureText).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/web/src/components/features/ChatInput/location/snapshot.ts b/apps/web/src/components/features/ChatInput/location/snapshot.ts
new file mode 100644
index 000000000..a3056d3e9
--- /dev/null
+++ b/apps/web/src/components/features/ChatInput/location/snapshot.ts
@@ -0,0 +1,103 @@
+import { tilesFor, type LocationMeta, type MapTile } from "@lib/location"
+import { PIN_COLOR, PIN_HEIGHT, PIN_PATH, PIN_WIDTH } from "./pinShape"
+
+export const SNAPSHOT_SIZE = 128
+export const SNAPSHOT_TIMEOUT_MS = 5000
+export const SNAPSHOT_MIN_ZOOM = 15
+export const SNAPSHOT_MAX_ZOOM = 18
+
+export type Snapshot = { file_url: string; file_size: number }
+export type SnapshotDrawPlan = { tiles: MapTile[]; pin: { x: number; y: number } }
+
+const PIN_DOT_COLOR = "#ffffff"
+
+// Leaflet's zoom can be fractional mid-animation; tile URLs need an integer.
+const clampZoom = (zoom: number): number =>
+ Math.min(SNAPSHOT_MAX_ZOOM, Math.max(SNAPSHOT_MIN_ZOOM, Math.round(zoom)))
+
+/** Tiles and pin placement for the 128 px snapshot, centred on the point. */
+export const snapshotDrawPlan = (meta: LocationMeta, zoom: number): SnapshotDrawPlan => ({
+ tiles: tilesFor(meta.latitude, meta.longitude, clampZoom(zoom), SNAPSHOT_SIZE, SNAPSHOT_SIZE, 256),
+ pin: { x: SNAPSHOT_SIZE / 2, y: SNAPSHOT_SIZE / 2 },
+})
+
+/** `promise`'s value, or null once `ms` passes; rejections become null too. */
+export const withTimeout = edited somewhere setFailedSnapshot(snapshotUrl)}
- />
- ) : (
-
- )}
-
setFailedSnapshot(snapshotUrl)}
+ />
+ ) : (
+