A Bluesky Playdate client
at main 1243 lines 48 kB view raw
1const std = @import("std"); 2const pdapi = @import("playdate_api_definitions.zig"); 3const network = @import("network.zig"); 4const bsky_post = @import("bsky_post.zig"); 5const pdtools = @import("pdtools/index.zig"); 6const ScrollingValue = @import("pdtools/ScrollingValue.zig").SlidingValue; 7const panic_handler = @import("panic_handler.zig"); 8const defs = @import("definitions.zig"); 9const keyboard_mod = @import("pdtools/keyboard.zig"); 10const Keyboard = keyboard_mod.Keyboard(256); 11 12const fonts = @import("fonts.zig"); 13 14// Global buffers to avoid large stack allocations 15var g_headers_buffer: [512]u8 = undefined; 16 17// Login input state 18const LoginInputState = enum { 19 username, 20 password, 21 ready, 22}; 23 24// Login field selection 25const LoginFieldSelection = enum { 26 username, 27 password, 28 login_button, 29}; 30 31// Post field selection 32const PostFieldSelection = enum { 33 edit_text, 34 confirm_post, 35}; 36 37// Simple app state for timeline fetching 38const AppState = struct { 39 playdate: *pdapi.PlaydateAPI, 40 message: []const u8, 41 response_buffer: [defs.RESPONSE_BUFFER_SIZE]u8 = undefined, 42 response_length: usize, 43 44 posts: [bsky_post.MAX_POSTS]bsky_post.BskyPost, 45 post_count: usize, 46 cursor_buffer: [bsky_post.CURSOR_BUFFER_SIZE]u8, 47 cursor_len: usize, 48 49 network_access_requested: bool, 50 body_scroll: ScrollingValue, 51 52 // Login state 53 is_logged_in: bool, 54 access_token: [512]u8, 55 access_token_len: usize, 56 user_did: [256]u8, 57 user_did_len: usize, 58 59 keyboard: keyboard_mod.Keyboard(256), 60 61 // Login input state 62 login_input_state: LoginInputState, 63 login_field_selection: LoginFieldSelection, 64 username: [defs.MAX_CREDENTIAL_LENGTH]u8, 65 password: [defs.MAX_CREDENTIAL_LENGTH]u8, 66 67 previous_buttons: pdapi.PDButtons, 68 69 // Post composition state 70 post_field_selection: PostFieldSelection, 71 post_text: [defs.EDITOR_BUFFER_SIZE]u8, 72 post_text_len: usize, 73}; 74 75var g_app: AppState = undefined; 76 77const Page = enum { 78 login, 79 home, 80 post, 81}; 82 83var g_selected_page: Page = .login; 84 85const LCD_WIDTH = pdapi.LCD_COLUMNS; 86const LCD_HEIGHT = pdapi.LCD_ROWS; 87const MARGIN = 4; 88const CHUNK_SIZE = 512; // Read 512 bytes at a time 89 90// Credential storage functions 91fn saveCredentials(pd: *pdapi.PlaydateAPI) void { 92 pdtools.saveStringFile(pd, defs.USERNAME_FILENAME, &g_app.username); 93 pdtools.saveStringFile(pd, defs.PASSWORD_FILENAME, &g_app.password); 94} 95 96fn loadCredentials(pd: *pdapi.PlaydateAPI) void { 97 pd.system.logToConsole("Loading username from: %s", defs.USERNAME_FILENAME.ptr); 98 pdtools.loadStringFile(pd, defs.USERNAME_FILENAME, &g_app.username); 99 pd.system.logToConsole("Loading password from: %s", defs.PASSWORD_FILENAME.ptr); 100 pdtools.loadStringFile(pd, defs.PASSWORD_FILENAME, &g_app.password); 101} 102 103fn clearCredentials(pd: *pdapi.PlaydateAPI) void { 104 // Clear in-memory credentials 105 @memset(&g_app.username, 0); 106 @memset(&g_app.password, 0); 107 g_app.login_input_state = .username; 108 g_app.login_field_selection = .username; 109 110 pdtools.delStringFile(pd, defs.USERNAME_FILENAME); 111 pdtools.delStringFile(pd, defs.PASSWORD_FILENAME); 112 113 pd.system.logToConsole("Credentials cleared"); 114 g_app.message = "Credentials cleared - enter new ones"; 115} 116 117// Network callback functions 118fn onLoginSuccess(status_code: network.HttpStatusCode, response_data: []const u8) void { 119 const pd = g_app.playdate; 120 pd.system.logToConsole("Login successful with status: %d", status_code); 121 122 // Parse the JSON to extract access token 123 if (std.mem.indexOf(u8, response_data, "\"accessJwt\":\"")) |start_pos| { 124 const token_start = start_pos + 13; // Length of "accessJwt":" 125 if (std.mem.indexOfPos(u8, response_data, token_start, "\"")) |end_pos| { 126 const token_len = end_pos - token_start; 127 if (token_len < g_app.access_token.len) { 128 @memcpy(g_app.access_token[0..token_len], response_data[token_start..end_pos]); 129 g_app.access_token_len = token_len; 130 131 // Also parse the DID from the response 132 if (std.mem.indexOf(u8, response_data, "\"did\":\"")) |did_start_pos| { 133 const did_token_start = did_start_pos + 7; // Length of "did":" 134 if (std.mem.indexOfPos(u8, response_data, did_token_start, "\"")) |did_end_pos| { 135 const did_len = did_end_pos - did_token_start; 136 if (did_len < g_app.user_did.len) { 137 @memcpy(g_app.user_did[0..did_len], response_data[did_token_start..did_end_pos]); 138 g_app.user_did[did_len] = 0; 139 g_app.user_did_len = did_len; 140 pd.system.logToConsole("User DID extracted: %.*s", @as(c_int, @intCast(did_len)), @as([*c]const u8, @ptrCast(&g_app.user_did))); 141 } 142 } 143 } 144 145 g_app.is_logged_in = true; 146 g_app.message = "Login successful - fetching posts..."; 147 g_selected_page = .home; 148 pd.system.logToConsole("Access token extracted successfully (length: %d)", @as(c_int, @intCast(token_len))); 149 150 // Automatically fetch posts after successful login 151 fetchBlueskyFeed(); 152 } else { 153 g_app.message = "Access token too long"; 154 pd.system.logToConsole("Access token too long: %d", @as(c_int, @intCast(token_len))); 155 } 156 } else { 157 g_app.message = "Failed to parse access token"; 158 pd.system.logToConsole("Failed to find end of access token"); 159 } 160 } else { 161 g_app.message = "Access token not found"; 162 pd.system.logToConsole("Access token not found in response"); 163 } 164} 165 166fn onLoginFailure(status_code: network.HttpStatusCode, error_message: []const u8) void { 167 const pd = g_app.playdate; 168 pd.system.logToConsole("Login failed with status: %d", status_code); 169 pd.system.logToConsole("Error: %.*s", @as(c_int, @intCast(error_message.len)), @as([*c]const u8, @ptrCast(error_message.ptr))); 170 g_app.message = if (status_code == 0) "Login connection error" else "Login failed"; 171} 172 173fn onFeedSuccess(status_code: network.HttpStatusCode, response_data: []const u8) void { 174 const pd = g_app.playdate; 175 pd.system.logToConsole("Feed fetch successful with status: %d, %d bytes", status_code, response_data.len); 176 177 // The response_data is already pointing to our g_app.response_buffer 178 // So we just need to set the length - no copying needed 179 g_app.response_length = response_data.len; 180 181 // Ensure null termination 182 if (g_app.response_length < g_app.response_buffer.len) { 183 g_app.response_buffer[g_app.response_length] = 0; 184 } 185 186 g_app.message = "Feed data received"; 187 188 // Parse the JSON response into posts 189 parseFeedResponseIntoPosts(); 190} 191 192fn onFeedFailure(status_code: network.HttpStatusCode, error_message: []const u8) void { 193 const pd = g_app.playdate; 194 pd.system.logToConsole("Feed fetch failed with status: %d", status_code); 195 196 if (error_message.len > 0) { 197 pd.system.logToConsole("Error message: %.*s", @as(c_int, @intCast(error_message.len)), @as([*c]const u8, @ptrCast(error_message.ptr))); 198 } else { 199 pd.system.logToConsole("No error message provided"); 200 } 201 202 // Clear any previous response data 203 g_app.response_length = 0; 204 g_app.post_count = 0; 205 206 if (status_code == 0) { 207 g_app.message = "Network connection failed"; 208 } else if (status_code == 401) { 209 g_app.message = "Login expired - please re-login"; 210 } else if (status_code >= 400 and status_code < 500) { 211 g_app.message = "Request error - check credentials"; 212 } else if (status_code >= 500) { 213 g_app.message = "Server error - try again later"; 214 } else { 215 g_app.message = "Feed fetch failed"; 216 } 217} 218 219fn onPostSuccess(status_code: network.HttpStatusCode, response_data: []const u8) void { 220 const pd = g_app.playdate; 221 pd.system.logToConsole("Post successful with status: %d", status_code); 222 223 // Clear the post text since it was successfully posted 224 @memset(&g_app.post_text, 0); 225 g_app.post_text_len = 0; 226 227 // Reset to default text for next post 228 const default_post_text = "Hello World!\n\nPosted from my #playdate"; 229 const default_len = @min(default_post_text.len, g_app.post_text.len - 1); 230 @memcpy(g_app.post_text[0..default_len], default_post_text[0..default_len]); 231 g_app.post_text[default_len] = 0; 232 g_app.post_text_len = default_len; 233 234 g_app.message = "Post published successfully!"; 235 236 // Log response for debugging 237 if (response_data.len > 0) { 238 const preview_len = @min(100, response_data.len); 239 pd.system.logToConsole("Post response: %.*s", @as(c_int, @intCast(preview_len)), @as([*c]const u8, @ptrCast(response_data.ptr))); 240 } 241} 242 243fn onPostFailure(status_code: network.HttpStatusCode, error_message: []const u8) void { 244 const pd = g_app.playdate; 245 pd.system.logToConsole("Post failed with status: %d", status_code); 246 247 if (error_message.len > 0) { 248 pd.system.logToConsole("Post error: %.*s", @as(c_int, @intCast(error_message.len)), @as([*c]const u8, @ptrCast(error_message.ptr))); 249 } 250 251 if (status_code == 0) { 252 g_app.message = "Post failed - network error"; 253 } else if (status_code == 401) { 254 g_app.message = "Post failed - please re-login"; 255 } else if (status_code == 413) { 256 g_app.message = "Post too long - please shorten"; 257 } else if (status_code >= 400 and status_code < 500) { 258 g_app.message = "Post failed - invalid request"; 259 } else if (status_code >= 500) { 260 g_app.message = "Post failed - server error"; 261 } else { 262 g_app.message = "Post failed - unknown error"; 263 } 264} 265 266fn parseFeedResponseIntoPosts() void { 267 // Check if we have a valid response 268 if (g_app.response_length == 0) { 269 g_app.playdate.system.logToConsole("No response data to parse"); 270 g_app.message = "No response data received"; 271 return; 272 } 273 274 // Validate response length isn't too large 275 if (g_app.response_length >= g_app.response_buffer.len) { 276 g_app.playdate.system.logToConsole("WARNING: Response length %d exceeds buffer size %d", @as(c_int, @intCast(g_app.response_length)), @as(c_int, @intCast(g_app.response_buffer.len))); 277 g_app.response_length = g_app.response_buffer.len - 1; 278 } 279 280 g_app.playdate.system.logToConsole("Starting JSON parsing of %d bytes", @as(c_int, @intCast(g_app.response_length))); 281 282 // Log first few characters for debugging 283 const preview_len = @min(100, g_app.response_length); 284 g_app.playdate.system.logToConsole("Response preview (first %d chars): %.*s", @as(c_int, @intCast(preview_len)), @as(c_int, @intCast(preview_len)), @as([*]const u8, @ptrCast(&g_app.response_buffer))); 285 286 // Reset post count before parsing 287 g_app.post_count = 0; 288 289 // Add error handling around JSON parsing 290 bsky_post.decodePostsJson( 291 g_app.playdate, 292 &g_app.response_buffer, 293 g_app.response_length, 294 g_app.posts.len, 295 &g_app.posts, 296 &g_app.post_count, 297 &g_app.cursor_buffer, 298 &g_app.cursor_len, 299 ); 300 301 g_app.playdate.system.logToConsole("JSON parsing completed, found %d posts", @as(c_int, @intCast(g_app.post_count))); 302 303 if (g_app.post_count == 0) { 304 g_app.message = "No posts found in response"; 305 } else { 306 g_app.message = "Feed loaded successfully"; 307 } 308} 309 310fn onScrollMaxThreshold() void { 311 // Handle scroll max threshold being reached 312 g_app.playdate.system.logToConsole("Scroll max threshold reached"); 313} 314 315fn onScrollMinThreshold() void { 316 // Handle scroll min threshold being reached 317 g_app.playdate.system.logToConsole("Scroll min threshold reached"); 318 319 // Refresh posts. 320 g_app.playdate.system.logToConsole("Refreshing posts..."); 321 fetchBlueskyFeed(); 322} 323 324pub export fn eventHandler(playdate: *pdapi.PlaydateAPI, event: pdapi.PDSystemEvent, arg: u32) callconv(.C) c_int { 325 _ = arg; 326 switch (event) { 327 .EventInit => { 328 playdate.system.logToConsole("Initializing timeline fetch app..."); 329 330 // Initialize panic handler for better error reporting 331 panic_handler.init(playdate); 332 333 // Test basic memory allocation to catch early issues 334 playdate.system.logToConsole("Testing basic memory allocation..."); 335 336 playdate.system.logToConsole("Memory test passed - AppState size: %d bytes", @as(c_int, @intCast(@sizeOf(AppState)))); 337 338 playdate.system.logToConsole("Initializing full application"); 339 340 // Initialize full app state 341 g_app.playdate = playdate; 342 343 fonts.initializeFonts(playdate); 344 345 // Initialize keyboard 346 g_app.keyboard = keyboard_mod.Keyboard(256){ 347 .playdate = playdate, 348 .font = fonts.g_font, 349 .title = undefined, 350 .editor_buffer = undefined, 351 .output_buffer = undefined, 352 }; 353 354 g_app.message = "Use ← → to navigate, B to interact"; 355 g_app.response_length = 0; 356 g_app.post_count = 0; 357 g_app.cursor_len = 0; 358 g_app.network_access_requested = false; 359 360 // Initialize scrolling value for posts with reasonable bounds 361 g_app.body_scroll = ScrollingValue{ 362 .playdate = playdate, 363 .soft_min_runout = 100, // Static runout distance from min_value 364 .soft_max_runout = 100, // Static runout distance from max_value 365 .runout_margin = 5.0, // Margin before callback detection 366 .min_value = -200, // Allow scrolling above top with hard limit 367 .max_value = 2000, // Hard limit - will be updated based on content 368 .current_value = 0, 369 .current_height = 0, 370 .crank_position_offset = 0, 371 .onScrollMaxThreshold = &onScrollMaxThreshold, 372 .onScrollMinThreshold = &onScrollMinThreshold, 373 }; 374 g_app.is_logged_in = false; 375 g_app.access_token_len = 0; 376 g_app.user_did_len = 0; 377 // Initialize login state with default credentials for testing 378 g_app.login_input_state = .ready; // Start ready since we have default credentials 379 g_app.login_field_selection = .username; // Start with username selected 380 381 // Initialize with empty credentials first 382 @memset(&g_app.username, 0); 383 @memset(&g_app.password, 0); 384 385 g_app.previous_buttons = 0; 386 387 // Initialize post composition state with default text 388 g_app.post_field_selection = .edit_text; 389 const default_post_text = "Hello World!\n\nPosted from my #playdate"; 390 const default_len = @min(default_post_text.len, g_app.post_text.len - 1); 391 @memcpy(g_app.post_text[0..default_len], default_post_text[0..default_len]); 392 g_app.post_text[default_len] = 0; 393 g_app.post_text_len = default_len; 394 395 // Set up real update callback with full functionality 396 playdate.system.setUpdateCallback(update_and_render, null); 397 playdate.system.logToConsole("Update callback set"); 398 399 playdate.system.logToConsole("App initialized successfully"); 400 401 playdate.graphics.clear(@intFromEnum(pdapi.LCDSolidColor.ColorWhite)); 402 403 // Try to load saved credentials 404 playdate.system.logToConsole("Attempting to load saved credentials..."); 405 loadCredentials(playdate); 406 g_app.login_input_state = .ready; 407 playdate.system.logToConsole("Credentials loaded - Username: %s", &g_app.username); 408 g_app.message = "Saved credentials loaded"; 409 }, 410 411 else => {}, 412 } 413 return 0; 414} 415 416fn loginToBluesky() void { 417 const pd = g_app.playdate; 418 419 // Check if we're already busy with a network operation 420 if (!network.isNetworkIdle()) { 421 return; 422 } 423 424 // Request network access 425 if (!g_app.network_access_requested) { 426 pd.system.logToConsole("Requesting network access for https://bsky.social..."); 427 const response = pd.network.playdate_http.requestAccess("https://bsky.social", 443, true, "login", null, null); 428 g_app.network_access_requested = true; 429 430 switch (response) { 431 .AccessAllow => { 432 pd.system.logToConsole("Network access GRANTED"); 433 }, 434 .AccessDeny => { 435 pd.system.logToConsole("Network access DENIED by user"); 436 g_app.message = "Network access denied"; 437 return; 438 }, 439 else => { 440 pd.system.logToConsole("Network access request FAILED"); 441 g_app.message = "Access request failed"; 442 return; 443 }, 444 } 445 } else { 446 pd.system.logToConsole("Network access already requested"); 447 } 448 449 // Check if we have credentials 450 if (g_app.username[0] == 0 or g_app.password[0] == 0) { 451 g_app.message = "Please enter username and password"; 452 g_app.login_input_state = if (g_app.username[0] == 0) .username else .password; 453 return; 454 } 455 456 // Prepare login request body using entered credentials 457 var login_body: [512]u8 = undefined; 458 const username = std.mem.span(@as([*:0]const u8, @ptrCast(&g_app.username))); 459 const password = std.mem.span(@as([*:0]const u8, @ptrCast(&g_app.password))); 460 461 // Debug logging for credentials 462 pd.system.logToConsole("Login credentials - Username: %s", @as([*:0]const u8, @ptrCast(&g_app.username))); 463 pd.system.logToConsole("Login credentials - Password: %s", @as([*:0]const u8, @ptrCast(&g_app.password))); 464 const login_json = std.fmt.bufPrint(&login_body, "{{\"identifier\":\"{s}\",\"password\":\"{s}\"}}", .{ username, password }) catch { 465 g_app.message = "Failed to format login request"; 466 return; 467 }; 468 469 // Create HTTP request 470 const request = network.HttpRequest{ 471 .method = .POST, 472 .url = "https://bsky.social/xrpc/com.atproto.server.createSession", 473 .server = "bsky.social", 474 .port = 443, 475 .path = "/xrpc/com.atproto.server.createSession", 476 .use_https = true, 477 .bearer_token = null, 478 .body = login_json, 479 .response_buffer = &g_app.response_buffer, 480 .success_callback = onLoginSuccess, 481 .failure_callback = onLoginFailure, 482 }; 483 484 if (network.makeHttpRequest(pd, request)) { 485 g_app.message = "Logging in..."; 486 } else { 487 g_app.message = "Network connection failed - check internet"; 488 pd.system.logToConsole("Troubleshooting suggestions:"); 489 pd.system.logToConsole("- Check internet connection"); 490 pd.system.logToConsole("- Try in Playdate simulator vs device"); 491 pd.system.logToConsole("- Check firewall settings"); 492 pd.system.logToConsole("- Verify DNS resolution works"); 493 } 494} 495 496fn fetchBlueskyFeed() void { 497 const pd = g_app.playdate; 498 499 // Check if logged in 500 if (!g_app.is_logged_in) { 501 g_app.message = "Please log in first"; 502 return; 503 } 504 505 // Check if we're already busy with a network operation 506 if (!network.isNetworkIdle()) { 507 return; 508 } 509 510 // Reset state for refetch 511 g_app.post_count = 0; 512 g_app.body_scroll.setValue(0); 513 514 // Request network access only once 515 if (!g_app.network_access_requested) { 516 pd.system.logToConsole("Requesting network access..."); 517 const response = pd.network.playdate_http.requestAccess("https://bsky.social", 443, true, "timeline", null, null); 518 g_app.network_access_requested = true; 519 520 switch (response) { 521 .AccessAllow => {}, 522 .AccessDeny => { 523 g_app.message = "Network access denied"; 524 return; 525 }, 526 else => { 527 g_app.message = "Access request failed"; 528 return; 529 }, 530 } 531 } 532 533 // Create HTTP request with bearer token 534 const bearer_token = g_app.access_token[0..g_app.access_token_len]; 535 const request = network.HttpRequest{ 536 .method = .GET, 537 .url = "https://bsky.social/xrpc/app.bsky.feed.getTimeline?limit=50", 538 .server = "bsky.social", 539 .port = 443, 540 .path = "/xrpc/app.bsky.feed.getTimeline?limit=50", 541 .use_https = true, 542 .bearer_token = bearer_token, 543 .body = null, 544 .response_buffer = &g_app.response_buffer, 545 .success_callback = onFeedSuccess, 546 .failure_callback = onFeedFailure, 547 }; 548 549 if (network.makeHttpRequest(pd, request)) { 550 g_app.message = "Fetching feed data..."; 551 } else { 552 g_app.message = "Failed to start feed request"; 553 } 554} 555 556// Login keyboard callbacks 557fn usernameKeyboardCancelled() void { 558 g_app.playdate.system.logToConsole("Username input cancelled"); 559 g_app.login_input_state = .username; // Stay on username input 560} 561 562fn usernameKeyboardConfirmed(text: []const u8) void { 563 g_app.playdate.system.logToConsole("Username entered: %.*s", @as(c_int, @intCast(text.len)), @as([*c]const u8, @ptrCast(text.ptr))); 564 565 // Copy username 566 const copy_len = @min(text.len, g_app.username.len - 1); 567 @memcpy(g_app.username[0..copy_len], text[0..copy_len]); 568 g_app.username[copy_len] = 0; 569 570 saveCredentials(g_app.playdate); 571 572 // Move to password input 573 g_app.login_input_state = .password; 574} 575 576fn passwordKeyboardCancelled() void { 577 g_app.playdate.system.logToConsole("Password input cancelled"); 578 g_app.login_input_state = .username; // Go back to username input 579} 580 581fn passwordKeyboardConfirmed(text: []const u8) void { 582 g_app.playdate.system.logToConsole("Password entered (length: %d)", @as(c_int, @intCast(text.len))); 583 584 // Copy password 585 const copy_len = @min(text.len, g_app.password.len - 1); 586 @memcpy(g_app.password[0..copy_len], text[0..copy_len]); 587 g_app.password[copy_len] = 0; 588 589 saveCredentials(g_app.playdate); 590 591 g_app.login_input_state = .ready; 592} 593 594// JSON writer context for building post JSON 595const JsonWriteContext = struct { 596 buffer: []u8, 597 pos: usize, 598}; 599 600// JSON write callback for Playdate JSON encoder 601fn jsonWriteCallback(userdata: ?*anyopaque, str: [*c]const u8, len: c_int) callconv(.C) void { 602 if (userdata == null) return; 603 const context: *JsonWriteContext = @ptrCast(@alignCast(userdata.?)); 604 const string_len = @as(usize, @intCast(len)); 605 606 if (context.pos + string_len > context.buffer.len) return; // Buffer overflow protection 607 608 const src_slice = @as([*]const u8, @ptrCast(str))[0..string_len]; 609 @memcpy(context.buffer[context.pos .. context.pos + string_len], src_slice); 610 context.pos += string_len; 611} 612 613fn submitBlueskyPost() void { 614 const pd = g_app.playdate; 615 616 // Check if logged in 617 if (!g_app.is_logged_in) { 618 g_app.message = "Please log in first"; 619 return; 620 } 621 622 // Check if we have post content 623 if (g_app.post_text_len == 0) { 624 g_app.message = "Please enter post content first"; 625 return; 626 } 627 628 // Check if we're already busy with a network operation 629 if (!network.isNetworkIdle()) { 630 return; 631 } 632 633 // Request network access if not already done 634 if (!g_app.network_access_requested) { 635 const response = pd.network.playdate_http.requestAccess("https://bsky.social", 443, true, "post", null, null); 636 g_app.network_access_requested = true; 637 638 switch (response) { 639 .AccessAllow => {}, 640 .AccessDeny => { 641 g_app.message = "Network access denied"; 642 return; 643 }, 644 else => { 645 g_app.message = "Access request failed"; 646 return; 647 }, 648 } 649 } 650 651 // Get current timestamp in ISO 8601 format using accurate Playdate API 652 const seconds_since_2000 = pd.system.getSecondsSinceEpoch(null); 653 654 // Convert to PDDateTime for formatting 655 var datetime: pdapi.PDDateTime = undefined; 656 pd.system.convertEpochToDateTime(seconds_since_2000, &datetime); 657 658 // Format as ISO 8601 using the utility function 659 const timestamp = pdtools.datetimeToISO(datetime); 660 661 // Check if we have user DID 662 if (g_app.user_did_len == 0) { 663 g_app.message = "Missing user DID - please re-login"; 664 return; 665 } 666 667 // Prepare post request body using Playdate JSON encoder 668 var post_body: [1024]u8 = undefined; 669 var json_context = JsonWriteContext{ .buffer = &post_body, .pos = 0 }; 670 671 var encoder: pdapi.JSONEncoder = undefined; 672 pd.json.initEncoder(&encoder, jsonWriteCallback, &json_context, 0); 673 674 const post_text = g_app.post_text[0..g_app.post_text_len]; 675 const user_did = g_app.user_did[0..g_app.user_did_len]; 676 677 // Build JSON: {"repo":"...", "collection":"...", "record":{"text":"...", "createdAt":"..."}} 678 encoder.startTable(&encoder); 679 680 // Add repo field 681 encoder.addTableMember(&encoder, "repo", 4); 682 encoder.writeString(&encoder, @as([*c]const u8, @ptrCast(user_did.ptr)), @as(c_int, @intCast(user_did.len))); 683 684 // Add collection field 685 encoder.addTableMember(&encoder, "collection", 10); 686 encoder.writeString(&encoder, "app.bsky.feed.post", 18); 687 688 // Add record field 689 encoder.addTableMember(&encoder, "record", 6); 690 encoder.startTable(&encoder); 691 692 // Add text field to record 693 encoder.addTableMember(&encoder, "text", 4); 694 encoder.writeString(&encoder, @as([*c]const u8, @ptrCast(post_text.ptr)), @as(c_int, @intCast(post_text.len))); 695 696 // Add createdAt field to record 697 encoder.addTableMember(&encoder, "createdAt", 9); 698 encoder.writeString(&encoder, @as([*c]const u8, @ptrCast(timestamp.ptr)), @as(c_int, @intCast(timestamp.len))); 699 700 encoder.endTable(&encoder); // End record 701 encoder.endTable(&encoder); // End root 702 703 const post_json = post_body[0..json_context.pos]; 704 705 // Log json 706 pdtools.logLargeMessage(pd, post_json, post_json.len); 707 708 // Create HTTP request 709 if (defs.DEBUG_DONT_POST) { 710 g_app.message = "Debug mode - not posting"; 711 return; 712 } 713 714 const bearer_token = g_app.access_token[0..g_app.access_token_len]; 715 const request = network.HttpRequest{ 716 .method = .POST, 717 .url = "https://bsky.social/xrpc/com.atproto.repo.createRecord", 718 .server = "bsky.social", 719 .port = 443, 720 .path = "/xrpc/com.atproto.repo.createRecord", 721 .use_https = true, 722 .bearer_token = bearer_token, 723 .body = post_json, 724 .response_buffer = &g_app.response_buffer, 725 .success_callback = onPostSuccess, 726 .failure_callback = onPostFailure, 727 }; 728 729 if (network.makeHttpRequest(pd, request)) { 730 g_app.message = "Posting..."; 731 } else { 732 g_app.message = "Failed to start post request"; 733 } 734} 735 736// Post keyboard callbacks 737fn postKeyboardCancelled() void { 738 g_app.playdate.system.logToConsole("Post input cancelled"); 739 // Stay on post page, don't change anything 740} 741 742// New callback for text editing that doesn't auto-submit 743fn postTextEditConfirmed(text: []const u8) void { 744 g_app.playdate.system.logToConsole("Post text edited (length: %d)", @as(c_int, @intCast(text.len))); 745 746 // Copy post text but don't submit automatically 747 const copy_len = @min(text.len, g_app.post_text.len - 1); 748 @memcpy(g_app.post_text[0..copy_len], text[0..copy_len]); 749 g_app.post_text[copy_len] = 0; 750 g_app.post_text_len = copy_len; 751 752 g_app.message = "Post text updated - use confirm button to post"; 753} 754 755fn update_and_render(userdata: ?*anyopaque) callconv(.C) c_int { 756 _ = userdata; // Ignore userdata, use global state 757 758 // Safety check - ensure app is initialized 759 const pd = g_app.playdate; 760 761 // Update network processing 762 network.updateNetwork(pd); 763 764 if (g_app.keyboard.updateAndRender()) { 765 return 1; 766 } 767 768 // Handle button input 769 var current: pdapi.PDButtons = undefined; 770 var pushed: pdapi.PDButtons = undefined; 771 var released: pdapi.PDButtons = undefined; 772 pd.system.getButtonState(&current, &pushed, &released); 773 774 // Handle keyboard input first if keyboard is active 775 776 // Handle up/down navigation for login field selection 777 if (g_selected_page == .login and !g_app.is_logged_in) { 778 if (pushed & pdapi.BUTTON_UP != 0) { 779 g_app.login_field_selection = switch (g_app.login_field_selection) { 780 .username => .login_button, 781 .password => .username, 782 .login_button => .password, 783 }; 784 } else if (pushed & pdapi.BUTTON_DOWN != 0) { 785 g_app.login_field_selection = switch (g_app.login_field_selection) { 786 .username => .password, 787 .password => .login_button, 788 .login_button => .username, 789 }; 790 } 791 } 792 793 // Handle up/down navigation for post field selection 794 if (g_selected_page == .post and g_app.is_logged_in) { 795 if (pushed & pdapi.BUTTON_UP != 0) { 796 g_app.post_field_selection = switch (g_app.post_field_selection) { 797 .edit_text => .confirm_post, 798 .confirm_post => .edit_text, 799 }; 800 } else if (pushed & pdapi.BUTTON_DOWN != 0) { 801 g_app.post_field_selection = switch (g_app.post_field_selection) { 802 .edit_text => .confirm_post, 803 .confirm_post => .edit_text, 804 }; 805 } 806 } 807 808 // Check if left/right was pressed to change page selection 809 if (pushed & pdapi.BUTTON_LEFT != 0) { 810 g_selected_page = switch (g_selected_page) { 811 .login => if (g_app.is_logged_in) .post else .login, 812 .home => .login, 813 .post => .home, 814 }; 815 } else if (pushed & pdapi.BUTTON_RIGHT != 0) { 816 g_selected_page = switch (g_selected_page) { 817 .login => if (g_app.is_logged_in) .home else .login, 818 .home => if (g_app.is_logged_in) .post else .login, 819 .post => .login, 820 }; 821 } 822 823 // Check if B button was pushed (only if keyboard not active) 824 if (pushed & pdapi.BUTTON_B != 0) { 825 if (g_selected_page == .login and !g_app.is_logged_in) { 826 // Handle login input based on selected field 827 switch (g_app.login_field_selection) { 828 .username => { 829 // Start username input 830 const initial_text = if (g_app.username[0] != 0) std.mem.span(@as([*:0]const u8, @ptrCast(&g_app.username))) else null; 831 g_app.keyboard.start( 832 "Enter username:", 833 g_app.username.len, 834 initial_text, 835 false, 836 1, 837 usernameKeyboardCancelled, 838 usernameKeyboardConfirmed, 839 ); 840 }, 841 .password => { 842 // Start password input 843 const initial_text = if (g_app.password[0] != 0) std.mem.span(@as([*:0]const u8, @ptrCast(&g_app.password))) else null; 844 g_app.keyboard.start( 845 "Enter password:", 846 g_app.password.len, 847 initial_text, 848 false, 849 1, 850 passwordKeyboardCancelled, 851 passwordKeyboardConfirmed, 852 ); 853 }, 854 .login_button => { 855 // Check if we can login 856 if (g_app.username[0] > 0 and g_app.password[0] > 0) { 857 loginToBluesky(); 858 } else { 859 g_app.message = "Please enter username and password"; 860 } 861 }, 862 } 863 } else if (g_selected_page == .home) { 864 fetchBlueskyFeed(); 865 } else if (g_selected_page == .post) { 866 // Check if user is logged in before allowing post creation 867 if (!g_app.is_logged_in) { 868 g_app.message = "Please log in first to create posts"; 869 g_selected_page = .login; 870 } else { 871 // Handle post field selection 872 switch (g_app.post_field_selection) { 873 .edit_text => { 874 // Start post composition with keyboard (multiline enabled) 875 const initial_text = if (g_app.post_text_len > 0) g_app.post_text[0..g_app.post_text_len] else null; 876 g_app.keyboard.start("Enter your post:", 300, initial_text, true, 5, postKeyboardCancelled, postTextEditConfirmed); 877 }, 878 .confirm_post => { 879 // Submit the post directly 880 if (g_app.post_text_len > 0) { 881 submitBlueskyPost(); 882 } else { 883 g_app.message = "Please enter some text first"; 884 } 885 }, 886 } 887 } 888 } 889 } 890 891 // Render keyboard if active, otherwise render the selected page 892 switch (g_selected_page) { 893 .login => render_login(0, 20, LCD_WIDTH, LCD_HEIGHT - 20), 894 .home => render_home(0, 20, LCD_WIDTH, LCD_HEIGHT - 20), 895 .post => render_create_post(0, 20, LCD_WIDTH, LCD_HEIGHT - 20), 896 } 897 render_header(0, 0, LCD_WIDTH); 898 899 return 1; 900} 901 902fn render_header(x: c_int, y: c_int, w: c_int) void { 903 const pd = g_app.playdate; 904 905 const header_height = pd.graphics.getFontHeight(fonts.g_font) + MARGIN * 2; 906 907 // Draw header background 908 pd.graphics.fillRect( 909 x, 910 y, 911 w + MARGIN * 2, 912 header_height, 913 @intFromEnum(pdapi.LCDSolidColor.ColorBlack), 914 ); 915 916 // Set draw mode for white text on black background 917 pd.graphics.setDrawMode(pdapi.LCDBitmapDrawMode.DrawModeFillWhite); 918 919 // Draw header text 920 const header_text = "Bluesky"; 921 _ = pd.graphics.drawText( 922 header_text.ptr, 923 header_text.len, 924 pdapi.PDStringEncoding.UTF8Encoding, 925 x + MARGIN, 926 y + MARGIN, 927 ); 928 929 const text_width = pd.graphics.getTextWidth( 930 fonts.g_font, 931 header_text.ptr, 932 header_text.len, 933 pdapi.PDStringEncoding.UTF8Encoding, 934 0, 935 ); 936 937 // spinning loading indicator. 938 // Spinning loading indicator based on network state 939 const current_network_state = network.getNetworkState(); 940 if (current_network_state == .connecting or current_network_state == .requesting) { 941 pdtools.renderSpinner(pd, x + w - 20, y + 2, pdapi.LCDSolidColor.ColorWhite); 942 } 943 944 // Reset draw mode to normal 945 pd.graphics.setDrawMode(pdapi.LCDBitmapDrawMode.DrawModeCopy); 946 947 // Show all available pages 948 var temp_w: c_int = 0; 949 var curr_x = text_width + x + MARGIN * 2; 950 951 // Login button (always available) 952 pdtools.renderButton( 953 pd, 954 curr_x, 955 y, 956 "Login", 957 g_selected_page == .login, 958 &temp_w, 959 null, 960 ); 961 962 if (g_app.is_logged_in) { 963 curr_x += temp_w; 964 pdtools.renderButton( 965 pd, 966 curr_x, 967 y, 968 "Home", 969 g_selected_page == .home, 970 &temp_w, 971 null, 972 ); 973 974 curr_x += temp_w; 975 pdtools.renderButton( 976 pd, 977 curr_x, 978 y, 979 "Post", 980 g_selected_page == .post, 981 null, 982 null, 983 ); 984 } 985} 986 987fn render_post_list(x: c_int, y: c_int, w: c_int, h: c_int) void { 988 const scrollbar_width = 10; 989 const post_gap = 4; 990 const padding = 4; 991 const pd = g_app.playdate; 992 993 // Clip the whole thing so we don't go out of bounds 994 pd.graphics.setClipRect(x, y, w, h); 995 996 const posts_width = w - scrollbar_width - (padding * 2); 997 998 const start_y = @as(c_int, @intFromFloat(@as(f32, @floatFromInt(y)) - g_app.body_scroll.getValue())); 999 var current_y = start_y + padding; 1000 const current_x = x + padding; 1001 1002 // Now render each post with actual positions 1003 for (0..g_app.post_count) |i| { 1004 const post = &g_app.posts[i]; 1005 const post_height = bsky_post.renderPost( 1006 pd, 1007 post, 1008 current_x, 1009 current_y, 1010 posts_width, 1011 false, 1012 ); 1013 current_y += @as(c_int, @intCast(post_height)); 1014 current_y += post_gap; 1015 } 1016 current_y += padding; 1017 1018 // current_y is the scrolled position, so we need to add back the scroll offset to get true content height 1019 const content_height = current_y - start_y; 1020 1021 g_app.body_scroll.current_height = @as(f32, @floatFromInt(h)); 1022 g_app.body_scroll.min_value = 0; 1023 g_app.body_scroll.max_value = @floatFromInt(content_height); 1024 1025 // Render scrollbar on the right side (below header, taking remaining height) 1026 const scrollbar_x = pdapi.LCD_COLUMNS - scrollbar_width; 1027 pdtools.renderScrollbar( 1028 pd, 1029 scrollbar_x, 1030 y, 1031 scrollbar_width, 1032 h, 1033 g_app.body_scroll.getValue(), 1034 @as(f32, @floatFromInt(content_height)), 1035 @as(f32, @floatFromInt(h)), 1036 ); 1037 1038 pd.graphics.clearClipRect(); 1039} 1040 1041fn render_home(x: i32, y: i32, w: usize, h: usize) void { 1042 const pd = g_app.playdate; 1043 1044 // #region Body 1045 1046 // Update scrolling value with crank input 1047 g_app.body_scroll.update(); 1048 1049 pd.graphics.fillRect( 1050 @intCast(x), 1051 @intCast(y), 1052 @intCast(w), 1053 @intCast(h), 1054 @intFromEnum(pdapi.LCDSolidColor.ColorWhite), 1055 ); 1056 1057 // Set draw mode for black text on white background 1058 pd.graphics.setDrawMode(pdapi.LCDBitmapDrawMode.DrawModeFillBlack); 1059 1060 // Show login prompt if not logged in 1061 if (!g_app.is_logged_in) { 1062 _ = pd.graphics.drawText("Please log in first", 19, pdapi.PDStringEncoding.UTF8Encoding, x + MARGIN, y + MARGIN); 1063 pd.graphics.setDrawMode(pdapi.LCDBitmapDrawMode.DrawModeCopy); 1064 return; 1065 } 1066 1067 // Get current network state 1068 const current_network_state = network.getNetworkState(); 1069 1070 // Show loading progress if network is active 1071 if (current_network_state == .connecting or current_network_state == .requesting) { 1072 const progress_msg = "Loading feed..."; 1073 _ = pd.graphics.drawText(progress_msg.ptr, progress_msg.len, pdapi.PDStringEncoding.UTF8Encoding, x, y); 1074 1075 // Reset draw mode to normal 1076 pd.graphics.setDrawMode(pdapi.LCDBitmapDrawMode.DrawModeCopy); 1077 return; 1078 } 1079 1080 // If we have parsed posts, display them formatted 1081 if (g_app.post_count > 0) { 1082 render_post_list(@intCast(x), @intCast(y), @intCast(w), @intCast(h)); 1083 } else if (current_network_state == .success or current_network_state == .idle) { 1084 // Show message when no posts are available 1085 const no_posts_msg = "No posts found - Press B to refresh"; 1086 _ = pd.graphics.drawText(no_posts_msg.ptr, no_posts_msg.len, pdapi.PDStringEncoding.UTF8Encoding, x, y); 1087 } else { 1088 // Show current status message 1089 _ = pd.graphics.drawText(g_app.message.ptr, g_app.message.len, pdapi.PDStringEncoding.UTF8Encoding, x, y); 1090 } 1091 1092 // Reset draw mode to normal 1093 pd.graphics.setDrawMode(pdapi.LCDBitmapDrawMode.DrawModeCopy); 1094} 1095 1096fn render_login(x: c_int, y: c_int, w: c_int, h: c_int) void { 1097 const pd = g_app.playdate; 1098 1099 pd.graphics.fillRect(x, y, w, h, @intFromEnum(pdapi.LCDSolidColor.ColorWhite)); 1100 pd.graphics.setDrawMode(pdapi.LCDBitmapDrawMode.DrawModeFillBlack); 1101 1102 const line_height = pd.graphics.getFontHeight(fonts.g_font) + 4; 1103 var current_y = y + MARGIN; 1104 1105 const current_network_state = network.getNetworkState(); 1106 1107 if (current_network_state == .requesting or current_network_state == .connecting) { 1108 _ = pd.graphics.drawText("Logging in...", 12, pdapi.PDStringEncoding.UTF8Encoding, x + MARGIN, current_y); 1109 } else if (current_network_state == .network_error) { 1110 _ = pd.graphics.drawText("Login failed!", 13, pdapi.PDStringEncoding.UTF8Encoding, x + MARGIN, current_y); 1111 current_y += line_height; 1112 _ = pd.graphics.drawText("Press B to retry", 16, pdapi.PDStringEncoding.UTF8Encoding, x + MARGIN, current_y); 1113 } else { 1114 _ = pd.graphics.drawText("Welcome to Bluesky!", 20, pdapi.PDStringEncoding.UTF8Encoding, x + MARGIN, current_y); 1115 current_y += line_height * 2; 1116 1117 // Show current username with selection indicator 1118 var username_text: [200]u8 = undefined; 1119 const username_prefix = if (g_app.login_field_selection == .username) pdtools.SELECTION_ARROW ++ " Username: " else " Username: "; 1120 const username_value = if (g_app.username[0] != 0) std.mem.span(@as([*:0]const u8, @ptrCast(&g_app.username))) else "(not entered)"; 1121 const username_display = std.fmt.bufPrint(&username_text, "{s}{s}", .{ username_prefix, username_value }) catch "Username: (error)"; 1122 _ = pd.graphics.drawText(username_display.ptr, username_display.len, pdapi.PDStringEncoding.UTF8Encoding, x + MARGIN, current_y); 1123 current_y += line_height; 1124 1125 // Show password status with selection indicator 1126 const password_prefix = if (g_app.login_field_selection == .password) pdtools.SELECTION_ARROW ++ " Password: " else " Password: "; 1127 var password_text: [200]u8 = undefined; 1128 const password_display = if (g_app.password[0] != 0) blk: { 1129 // Generate dots based on actual password length using safe ASCII dots 1130 var dots_buffer: [64]u8 = undefined; 1131 const password_span = std.mem.span(@as([*:0]const u8, @ptrCast(&g_app.password))); 1132 const dots_len = @min(password_span.len, dots_buffer.len); 1133 @memset(dots_buffer[0..dots_len], '*'); 1134 const dots_str = dots_buffer[0..dots_len]; 1135 break :blk std.fmt.bufPrint(&password_text, "{s}{s}", .{ password_prefix, dots_str }) catch "Password: (error)"; 1136 } else std.fmt.bufPrint(&password_text, "{s}(not entered)", .{password_prefix}) catch "Password: (error)"; 1137 _ = pd.graphics.drawText(password_display.ptr, password_display.len, pdapi.PDStringEncoding.UTF8Encoding, x + MARGIN, current_y); 1138 current_y += line_height; 1139 1140 // Show login button with selection indicator 1141 const login_prefix = if (g_app.login_field_selection == .login_button) pdtools.SELECTION_ARROW ++ " " else " "; 1142 const login_text = if (g_app.username[0] != 0 and g_app.password[0] != 0) "Login" else "Login (enter credentials first)"; 1143 var login_button_text: [200]u8 = undefined; 1144 const login_display = std.fmt.bufPrint(&login_button_text, "{s}{s}", .{ login_prefix, login_text }) catch "Login"; 1145 _ = pd.graphics.drawText(login_display.ptr, login_display.len, pdapi.PDStringEncoding.UTF8Encoding, x + MARGIN, current_y); 1146 current_y += line_height * 2; 1147 1148 // Show navigation instructions 1149 const instruction = pdtools.UP_ARROW_EMOJI ++ pdtools.DOWN_ARROW_EMOJI ++ ": Select field B: Edit/Login"; 1150 _ = pd.graphics.drawText(instruction.ptr, instruction.len, pdapi.PDStringEncoding.UTF8Encoding, x + MARGIN, current_y); 1151 current_y += line_height; 1152 } 1153 1154 pd.graphics.setDrawMode(pdapi.LCDBitmapDrawMode.DrawModeCopy); 1155} 1156 1157fn render_create_post(x: c_int, y: c_int, w: c_int, h: c_int) void { 1158 const pd = g_app.playdate; 1159 1160 pd.graphics.fillRect(x, y, w, h, @intFromEnum(pdapi.LCDSolidColor.ColorWhite)); 1161 pd.graphics.setDrawMode(pdapi.LCDBitmapDrawMode.DrawModeFillBlack); 1162 1163 const line_height = pd.graphics.getFontHeight(fonts.g_font) + 4; 1164 var current_y = y + MARGIN; 1165 1166 // Check if user is logged in 1167 if (!g_app.is_logged_in) { 1168 _ = pd.graphics.drawText("Please log in first to create posts", 34, pdapi.PDStringEncoding.UTF8Encoding, x + MARGIN, current_y); 1169 pd.graphics.setDrawMode(pdapi.LCDBitmapDrawMode.DrawModeCopy); 1170 return; 1171 } 1172 1173 // Post composition page 1174 _ = pd.graphics.drawText("Create a Post", 13, pdapi.PDStringEncoding.UTF8Encoding, x + MARGIN, current_y); 1175 current_y += line_height; 1176 1177 // Show character count 1178 var char_count_text: [50]u8 = undefined; 1179 const char_count_display = std.fmt.bufPrint(&char_count_text, "Characters: {d}/300", .{g_app.post_text_len}) catch "Characters: 0/300"; 1180 _ = pd.graphics.drawText(char_count_display.ptr, char_count_display.len, pdapi.PDStringEncoding.UTF8Encoding, x + MARGIN, current_y); 1181 current_y += line_height; 1182 1183 // Show current post text if any 1184 if (g_app.post_text_len > 0) { 1185 _ = pd.graphics.drawText("Current post:", 13, pdapi.PDStringEncoding.UTF8Encoding, x + MARGIN, current_y); 1186 current_y += line_height; 1187 1188 // Draw a border around the post text area 1189 const text_area_height = line_height * 6; // Space for multiple lines 1190 pd.graphics.drawRect(x + MARGIN, current_y, w - MARGIN * 2, text_area_height, @intFromEnum(pdapi.LCDSolidColor.ColorBlack)); 1191 1192 // Render multiline post text 1193 const post_text = g_app.post_text[0..g_app.post_text_len]; 1194 var text_y = current_y + 4; 1195 var line_start: usize = 0; 1196 var displayed_lines: usize = 0; 1197 const max_lines = 5; 1198 1199 for (post_text, 0..) |char, i| { 1200 if (char == '\n' or i == post_text.len - 1) { 1201 if (displayed_lines >= max_lines) break; 1202 1203 const line_end = if (char == '\n') i else i + 1; 1204 const line_text = post_text[line_start..line_end]; 1205 1206 _ = pd.graphics.drawText(line_text.ptr, line_text.len, pdapi.PDStringEncoding.UTF8Encoding, x + MARGIN + 4, text_y); 1207 text_y += line_height; 1208 displayed_lines += 1; 1209 line_start = i + 1; 1210 } 1211 } 1212 1213 // If no newlines, display as single line 1214 if (displayed_lines == 0) { 1215 _ = pd.graphics.drawText(post_text.ptr, post_text.len, pdapi.PDStringEncoding.UTF8Encoding, x + MARGIN + 4, current_y + 4); 1216 } 1217 1218 current_y += text_area_height + line_height; 1219 } else { 1220 current_y += line_height * 2; 1221 _ = pd.graphics.drawText("No post drafted yet", 19, pdapi.PDStringEncoding.UTF8Encoding, x + MARGIN, current_y); 1222 current_y += line_height * 2; 1223 } 1224 1225 // Show field selection options 1226 const edit_prefix = if (g_app.post_field_selection == .edit_text) pdtools.SELECTION_ARROW ++ " " else " "; 1227 var edit_text: [100]u8 = undefined; 1228 const edit_display = std.fmt.bufPrint(&edit_text, "{s}Edit Post Text", .{edit_prefix}) catch "Edit Post Text"; 1229 _ = pd.graphics.drawText(edit_display.ptr, edit_display.len, pdapi.PDStringEncoding.UTF8Encoding, x + MARGIN, current_y); 1230 current_y += line_height; 1231 1232 const confirm_prefix = if (g_app.post_field_selection == .confirm_post) pdtools.SELECTION_ARROW ++ " " else " "; 1233 var confirm_text: [100]u8 = undefined; 1234 const confirm_display = std.fmt.bufPrint(&confirm_text, "{s}Confirm & Post", .{confirm_prefix}) catch "Confirm & Post"; 1235 _ = pd.graphics.drawText(confirm_display.ptr, confirm_display.len, pdapi.PDStringEncoding.UTF8Encoding, x + MARGIN, current_y); 1236 current_y += line_height * 2; 1237 1238 // Show navigation instructions 1239 const instruction = pdtools.UP_ARROW_EMOJI ++ pdtools.DOWN_ARROW_EMOJI ++ ": Select action B: Execute " ++ pdtools.LEFT_ARROW_EMOJI ++ pdtools.RIGHT_ARROW_EMOJI ++ ": Navigate tabs"; 1240 _ = pd.graphics.drawText(instruction.ptr, instruction.len, pdapi.PDStringEncoding.UTF8Encoding, x + MARGIN, current_y); 1241 1242 pd.graphics.setDrawMode(pdapi.LCDBitmapDrawMode.DrawModeCopy); 1243}