Skip to content

Commit 17d44bb

Browse files
authored
Add image response demo and refine image preview (#195)
* Add image response demo and refine image preview * Fix demo mock server Detekt warning
1 parent ecb6019 commit 17d44bb

6 files changed

Lines changed: 232 additions & 55 deletions

File tree

snapo-link-android/samples/demo-okhttp/src/main/java/com/openai/snapo/demo/MainActivity.kt

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ class MainActivity : ComponentActivity() {
7272
onPostRequestClick = { runPostRequest(scope) },
7373
onUnknownLengthGzipPostRequestClick = { runUnknownLengthGzipPostRequest(scope) },
7474
onNoContentTypeTextResponseClick = { runNoContentTypeTextResponseRequest(scope) },
75+
onImageResponseClick = { runImageResponseRequest(scope) },
7576
onSlowResponseClick = { runSlowResponseRequest(scope) },
7677
onWebSocketDemoClick = { startWebSocketDemo(scope) },
7778
modifier = Modifier.padding(innerPadding)
@@ -149,6 +150,17 @@ class MainActivity : ComponentActivity() {
149150
}
150151
}
151152

153+
private fun runImageResponseRequest(scope: CoroutineScope) {
154+
scope.launch {
155+
val url = resolveMockHttpUrl("/image.png") ?: return@launch
156+
val request = Request.Builder()
157+
.url(url)
158+
.header("X-SnapO-Demo", "okhttp-image-response")
159+
.build()
160+
executeRequest(scope, request, printResponseBody = false)
161+
}
162+
}
163+
152164
private fun runSlowResponseRequest(scope: CoroutineScope) {
153165
scope.launch {
154166
val url = resolveMockHttpUrl("/slow-response") ?: return@launch
@@ -170,9 +182,10 @@ class MainActivity : ComponentActivity() {
170182
try {
171183
call.executeAsync().use { response ->
172184
withContext(Dispatchers.IO) {
173-
val responseBody = response.body.string()
174185
if (printResponseBody) {
175-
println(responseBody)
186+
println(response.body.string())
187+
} else {
188+
response.body.bytes()
176189
}
177190
}
178191
}
@@ -243,6 +256,7 @@ fun Greeting(
243256
onPostRequestClick: () -> Unit,
244257
onUnknownLengthGzipPostRequestClick: () -> Unit,
245258
onNoContentTypeTextResponseClick: () -> Unit,
259+
onImageResponseClick: () -> Unit,
246260
onSlowResponseClick: () -> Unit,
247261
onWebSocketDemoClick: () -> Unit,
248262
modifier: Modifier = Modifier,
@@ -265,6 +279,9 @@ fun Greeting(
265279
Button(onClick = onNoContentTypeTextResponseClick) {
266280
Text("GET text (no Content-Type)")
267281
}
282+
Button(onClick = onImageResponseClick) {
283+
Text("GET image (PNG)")
284+
}
268285
Button(onClick = onSlowResponseClick) {
269286
Text("Slow response")
270287
}
@@ -283,6 +300,7 @@ private fun GreetingPreview() {
283300
onPostRequestClick = {},
284301
onUnknownLengthGzipPostRequestClick = {},
285302
onNoContentTypeTextResponseClick = {},
303+
onImageResponseClick = {},
286304
onSlowResponseClick = {},
287305
onWebSocketDemoClick = {},
288306
)

snapo-link-android/samples/demo-shared/src/main/java/com/openai/snapo/demo/shared/DemoMockServer.kt

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ import mockwebserver3.MockWebServer
77
import mockwebserver3.RecordedRequest
88
import okhttp3.WebSocket
99
import okhttp3.WebSocketListener
10+
import okio.Buffer
1011
import okio.ByteString
12+
import okio.ByteString.Companion.decodeBase64
1113
import java.net.InetAddress
1214
import java.util.concurrent.TimeUnit
1315

@@ -39,6 +41,7 @@ private fun createServer(): MockWebServer {
3941
val postBody = """{"ok":true,"endpoint":"post","source":"mockwebserver"}"""
4042
val gzipPostBody = """{"ok":true,"endpoint":"post-gzip-unknown-length","source":"mockwebserver"}"""
4143
val noTypeBody = """{"message":"Hello from Snap-O without Content-Type","source":"okhttp-demo"}"""
44+
val imageBody = checkNotNull(DemoAppIconPng.decodeBase64())
4245
val formBody = """{"ok":true,"endpoint":"form-post","source":"mockwebserver"}"""
4346
val slowBody = """{"message":"${"x".repeat(SlowBodyPayloadCharacters)}","source":"okhttp-demo"}"""
4447
return MockWebServer().apply {
@@ -80,6 +83,7 @@ private fun createServer(): MockWebServer {
8083
.setHeader("Connection", "close")
8184
.body(noTypeBody)
8285
.build()
86+
"/image.png" -> imageResponse(imageBody)
8387
"/slow-response" -> slowResponse(slowBody)
8488
"/ws-echo" -> MockResponse.Builder()
8589
.webSocketUpgrade(
@@ -103,6 +107,13 @@ private fun createServer(): MockWebServer {
103107
}
104108
}
105109

110+
private fun imageResponse(body: ByteString): MockResponse = MockResponse.Builder()
111+
.code(200)
112+
.setHeader("Content-Type", "image/png")
113+
.setHeader("Connection", "close")
114+
.body(Buffer().write(body))
115+
.build()
116+
106117
private fun slowResponse(body: String): MockResponse = MockResponse.Builder()
107118
.code(200)
108119
.setHeader("Content-Type", "application/json; charset=utf-8")
@@ -131,3 +142,65 @@ private const val SlowBodyPayloadCharacters: Int = 1024 * 1024
131142
private const val SlowBodyInitialDelayMs: Long = 2_000L
132143
private const val SlowBodyChunkBytes: Long = 128L * 1024L
133144
private const val SlowBodyChunkDelayMs: Long = 250L
145+
146+
private val DemoAppIconPng: String = """
147+
iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAMAAAD04JH5AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAHsUExURUdwTJDci5/jm1vOVDnJMTfHLjvI
148+
M6zyp0nPQH3idz/MNzvKMj7MNjjIL0HNOJ/umqTvn3nfclPTTDjJMLj2tHffcT3LNTzJM3nfc3TcboTjfoPjfkjPQF7WVnvidYLkfIflg7j3tXrfdJfqknTc
149+
bXDca5frkrr4t6fxo6fwoyO1GyS0HCS0GSa1GyW2GyO1Gf///yGzGSG0GiCyFyCxFyG2Gx2wFUXGPfn/9/b+9Su8IvP+8v7//bzoufH88Mzvyfr/+q/krByu
150+
FMDpvfb/9bfnteP44W/aaSq7Iim4Ief/5Nn/1u797sjuxjTELLTksTPFK83xy8DsvnPPbbvruMTtwcTrw+T641vFVZfek9Pz0eL/3uf75dj019v12vT989/5
151+
3tL/z1LCTOz/6MfwxS6/JSOvG+D33u//7Pv//CWzHG7badDwzev76/P/7sT2wdHxz0y+RSm1ITDCKLLmrk7LRsj7xVPOTOr86bbxs0O9O2XJX23MZzG2KH3T
152+
eNXx1De2L4rYhdb31Of45zy5Nc/8zHjRcrXssr31ukjIQCuzI/3/9iaxHj3DNTO7K6XmoXfccGfWYTO+K67uqsr/xqrlpxysE8Dwvcz3yYHVfK3pqVzSVRmt
153+
Ex+uF4njg9f91YXXf4XWgEfLPxurE6OFYTUAAAAqdFJOUwD+/vfB8f5kiWBoo2DfNIzM1h0FoOxZ0uH0o8ETtjQTBQX0tr/FNjIdHUb5dt8AABHjSURBVHja
154+
7Fj7c9vGEY5Uu46TurbrOHWdtpkknSTlHYgD+DBNcjIaDksLIMfTGXqGYEjKFBFTlDyShlRo0XyopCzJelBvy3rZkt3+o907ABRBSaCk/KodEQJwuNtvH7e3
155+
u598ckmXdEmXdEmX1I2+/v76zftf3b5399s1oOePHj16/ohe9Qt7eK6/Mugvd+/d/sf9T6///evfyPyf177rWVtbX9+fnJwsFosLCwsPzkDwWXGhWIQ5k/u/
156+
+9tnf7oo92+u3TpYW59cWN6QJLtNI6RdMLsi4yXibMfHOUla2VhefvD7/1758s5F+AP79eKyBEsy7vSC2T/Gzc7r7LqN2/s3Jh7f+MO52f8A7BeAO12Xp0vx
157+
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
205+
QmCC
206+
""".trimIndent()
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { describe, expect, it } from "vitest";
2+
import { contextMenuPosition } from "./ContextMenu";
3+
4+
describe("contextMenuPosition", () => {
5+
const viewport = { viewportWidth: 600, viewportHeight: 400 };
6+
const menu = { width: 190, height: 120 };
7+
8+
it("keeps a menu at its requested position when it fits", () => {
9+
expect(contextMenuPosition({ x: 180, y: 140, ...menu, ...viewport })).toEqual({ left: 180, top: 140 });
10+
});
11+
12+
it("moves a menu inside the right and bottom edges", () => {
13+
expect(contextMenuPosition({ x: 590, y: 390, ...menu, ...viewport })).toEqual({ left: 406, top: 276 });
14+
});
15+
16+
it("keeps a menu inside the left and top edges", () => {
17+
expect(contextMenuPosition({ x: -20, y: -10, ...menu, ...viewport })).toEqual({ left: 4, top: 4 });
18+
});
19+
20+
it("uses the inset when the menu is larger than the viewport", () => {
21+
expect(contextMenuPosition({ x: 300, y: 200, width: 800, height: 500, ...viewport })).toEqual({ left: 4, top: 4 });
22+
});
23+
});

snapo-network-inspector-web/src/features/network-inspector/components/ContextMenu.tsx

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { useLayoutEffect, useRef } from "react";
2+
13
export interface ContextMenuState {
24
x: number;
35
y: number;
@@ -10,9 +12,58 @@ export interface ContextMenuItem {
1012
disabled?: boolean;
1113
}
1214

15+
export function contextMenuPosition({
16+
x,
17+
y,
18+
width,
19+
height,
20+
viewportWidth,
21+
viewportHeight,
22+
inset = 4
23+
}: {
24+
x: number;
25+
y: number;
26+
width: number;
27+
height: number;
28+
viewportWidth: number;
29+
viewportHeight: number;
30+
inset?: number;
31+
}): { left: number; top: number } {
32+
return {
33+
left: Math.max(inset, Math.min(x, viewportWidth - width - inset)),
34+
top: Math.max(inset, Math.min(y, viewportHeight - height - inset))
35+
};
36+
}
37+
1338
export function ContextMenu({ menu, onClose }: { menu: ContextMenuState; onClose: () => void }): JSX.Element {
39+
const menuRef = useRef<HTMLDivElement>(null);
40+
41+
useLayoutEffect(() => {
42+
const element = menuRef.current;
43+
if (element == null) return;
44+
45+
const positionMenu = () => {
46+
const { width, height } = element.getBoundingClientRect();
47+
const { left, top } = contextMenuPosition({
48+
x: menu.x,
49+
y: menu.y,
50+
width,
51+
height,
52+
viewportWidth: window.innerWidth,
53+
viewportHeight: window.innerHeight
54+
});
55+
element.style.left = `${left}px`;
56+
element.style.top = `${top}px`;
57+
};
58+
59+
positionMenu();
60+
window.addEventListener("resize", positionMenu);
61+
return () => window.removeEventListener("resize", positionMenu);
62+
}, [menu]);
63+
1464
return (
1565
<div
66+
ref={menuRef}
1667
className="context-menu"
1768
style={{ left: menu.x, top: menu.y }}
1869
onPointerDown={(event) => event.stopPropagation()}

snapo-network-inspector-web/src/features/network-inspector/components/PayloadView.tsx

Lines changed: 59 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Check, Copy } from "lucide-react";
1+
import { Check, Copy, Download } from "lucide-react";
22
import { useEffect, useMemo, useState } from "react";
33
import type { NetworkClient } from "../../../network/client";
44
import {
@@ -449,39 +449,69 @@ function ImagePreview({ client, payload }: { client: NetworkClient; payload: Bod
449449
const dataUrl = dataUrlForImage(payload);
450450
const copyFeedback = useCopyFeedback("image");
451451
const saveFeedback = useCopyFeedback("save-image");
452+
const [menu, setMenu] = useState<ContextMenuState | null>(null);
453+
454+
useEffect(() => {
455+
if (menu == null) return;
456+
const close = () => setMenu(null);
457+
window.addEventListener("pointerdown", close);
458+
window.addEventListener("keydown", close);
459+
return () => {
460+
window.removeEventListener("pointerdown", close);
461+
window.removeEventListener("keydown", close);
462+
};
463+
}, [menu]);
464+
452465
if (dataUrl == null) return null;
466+
467+
const copyImage = () => {
468+
copyFeedback.copyWithoutClipboard();
469+
void copyImageToClipboard(dataUrl, payload.contentType ?? "image/png");
470+
};
471+
const saveImage = () => {
472+
void client
473+
.saveFile({
474+
defaultPath: imageFileName(payload.contentType),
475+
data: payload.rawText.replace(/\s+/gu, ""),
476+
mimeType: payload.contentType,
477+
encoding: "base64"
478+
})
479+
.then((result) => {
480+
if (result.saved) saveFeedback.copyWithoutClipboard();
481+
});
482+
};
483+
453484
return (
454485
<div className="image-preview-card">
455-
<div className="image-preview-header">
456-
<span>Image preview</span>
457-
<span className="image-content-type">{payload.contentType?.toUpperCase() ?? ""}</span>
458-
</div>
459486
<div className="image-actions">
460-
<InlineCopyButton
461-
copied={copyFeedback.copied}
462-
label="Copy Image"
463-
onCopy={() => {
464-
copyFeedback.copyWithoutClipboard();
465-
void copyImageToClipboard(dataUrl, payload.contentType ?? "image/png");
466-
}}
467-
/>
468-
<InlineTextToggle
469-
label={saveFeedback.copied ? "SAVED" : "SAVE AS..."}
470-
onClick={() => {
471-
void client
472-
.saveFile({
473-
defaultPath: imageFileName(payload.contentType),
474-
data: payload.rawText.replace(/\s+/gu, ""),
475-
mimeType: payload.contentType,
476-
encoding: "base64"
477-
})
478-
.then((result) => {
479-
if (result.saved) saveFeedback.copyWithoutClipboard();
480-
});
481-
}}
482-
/>
487+
<InlineCopyButton copied={copyFeedback.copied} label="Copy Image" onCopy={copyImage} iconOnly />
488+
<button
489+
className="inline-action inline-action-icon"
490+
type="button"
491+
aria-label={saveFeedback.copied ? "Saved" : "Save Image As..."}
492+
title={saveFeedback.copied ? "Saved" : "Save Image As..."}
493+
onClick={saveImage}
494+
>
495+
{saveFeedback.copied ? <Check size={14} /> : <Download size={14} />}
496+
</button>
483497
</div>
484-
<img className="image-preview" src={dataUrl} alt="" />
498+
<img
499+
className="image-preview"
500+
src={dataUrl}
501+
alt=""
502+
onContextMenu={(event) => {
503+
event.preventDefault();
504+
setMenu({
505+
x: event.clientX,
506+
y: event.clientY,
507+
items: [
508+
{ label: "Copy Image", action: copyImage },
509+
{ label: "Save Image As...", action: saveImage }
510+
]
511+
});
512+
}}
513+
/>
514+
{menu == null ? null : <ContextMenu menu={menu} onClose={() => setMenu(null)} />}
485515
</div>
486516
);
487517
}

0 commit comments

Comments
 (0)