mobile bluesky app made with flutter lazurite.stormlightlabs.org/
mobile bluesky flutter
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat (wip): dms

+2406 -18
+2
docs/BUGS.md
··· 2 2 title: Bugs 3 3 updated: 2026-03-18 4 4 --- 5 + 6 + Logging out crashes the app. It requires a restart to fix.
+1003
docs/designs/menu.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" /> 6 + <title>Home - Lazurite</title> 7 + <link rel="preconnect" href="https://fonts.googleapis.com" /> 8 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> 9 + <link href="https://fonts.googleapis.com/css2?family=Lora:wght@400;500;600;700&display=swap" rel="stylesheet" /> 10 + <link 11 + href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&display=swap" 12 + rel="stylesheet" /> 13 + <link rel="stylesheet" href="styles.css" /> 14 + <style> 15 + .feed-container { 16 + padding-bottom: 88px; 17 + } 18 + 19 + .compose-box { 20 + padding: 16px; 21 + border-bottom: 1px solid var(--border); 22 + background-color: var(--bg); 23 + } 24 + 25 + .compose-inner { 26 + display: flex; 27 + gap: 12px; 28 + } 29 + 30 + .compose-input-wrapper { 31 + flex: 1; 32 + } 33 + 34 + .compose-input { 35 + width: 100%; 36 + border: none; 37 + background: transparent; 38 + color: var(--text-primary); 39 + font-size: 16px; 40 + resize: none; 41 + outline: none; 42 + font-family: inherit; 43 + min-height: 24px; 44 + } 45 + 46 + .compose-input::placeholder { 47 + color: var(--text-muted); 48 + } 49 + 50 + .compose-actions { 51 + display: flex; 52 + justify-content: space-between; 53 + align-items: center; 54 + margin-top: 12px; 55 + padding-left: 60px; 56 + } 57 + 58 + .compose-tools { 59 + display: flex; 60 + gap: 8px; 61 + } 62 + 63 + .compose-tool { 64 + width: 36px; 65 + height: 36px; 66 + border-radius: 50%; 67 + border: none; 68 + background: transparent; 69 + color: var(--accent-primary); 70 + cursor: pointer; 71 + display: flex; 72 + align-items: center; 73 + justify-content: center; 74 + transition: background-color 0.2s ease; 75 + } 76 + 77 + .compose-tool:hover { 78 + background-color: rgba(0, 102, 255, 0.1); 79 + } 80 + 81 + .compose-tool svg { 82 + width: 20px; 83 + height: 20px; 84 + } 85 + 86 + .compose-submit { 87 + padding: 8px 20px; 88 + border-radius: 9999px; 89 + border: none; 90 + background-color: var(--accent-primary); 91 + color: white; 92 + font-weight: 600; 93 + font-size: 14px; 94 + cursor: pointer; 95 + transition: background-color 0.2s ease; 96 + } 97 + 98 + .compose-submit:hover { 99 + background-color: var(--accent-primary-hover); 100 + } 101 + 102 + .feed-tabs { 103 + display: flex; 104 + border-bottom: 1px solid var(--border); 105 + background-color: var(--bg); 106 + } 107 + 108 + .feed-tab { 109 + flex: 1; 110 + padding: 16px; 111 + text-align: center; 112 + font-weight: 600; 113 + font-size: 15px; 114 + color: var(--text-secondary); 115 + cursor: pointer; 116 + border-bottom: 2px solid transparent; 117 + transition: all 0.2s ease; 118 + background: none; 119 + border-top: none; 120 + border-left: none; 121 + border-right: none; 122 + } 123 + 124 + .feed-tab:hover { 125 + background-color: var(--surface); 126 + color: var(--text-primary); 127 + } 128 + 129 + .feed-tab.active { 130 + color: var(--text-primary); 131 + border-bottom-color: var(--accent-primary); 132 + } 133 + 134 + .post-facet-mention { 135 + color: var(--accent-primary); 136 + text-decoration: none; 137 + font-weight: 500; 138 + } 139 + 140 + .post-facet-mention:hover { 141 + text-decoration: underline; 142 + } 143 + 144 + .post-facet-hashtag { 145 + color: var(--accent-secondary); 146 + text-decoration: none; 147 + font-weight: 500; 148 + } 149 + 150 + .post-facet-hashtag:hover { 151 + text-decoration: underline; 152 + } 153 + 154 + .post-facet-link { 155 + color: var(--accent-primary); 156 + text-decoration: underline; 157 + } 158 + 159 + .post-embed { 160 + margin-top: 12px; 161 + border: 1px solid var(--border); 162 + border-radius: 12px; 163 + overflow: hidden; 164 + } 165 + 166 + .post-embed-image { 167 + width: 100%; 168 + height: 200px; 169 + background: linear-gradient(135deg, var(--surface) 0%, var(--surface-variant) 100%); 170 + display: flex; 171 + align-items: center; 172 + justify-content: center; 173 + color: var(--text-muted); 174 + } 175 + 176 + .post-embed-content { 177 + padding: 12px; 178 + } 179 + 180 + .post-embed-title { 181 + font-weight: 600; 182 + color: var(--text-primary); 183 + font-size: 14px; 184 + margin-bottom: 4px; 185 + } 186 + 187 + .post-embed-url { 188 + color: var(--text-muted); 189 + font-size: 12px; 190 + } 191 + 192 + .menu-overlay { 193 + position: fixed; 194 + top: 0; 195 + left: 0; 196 + right: 0; 197 + bottom: 0; 198 + background-color: rgba(0, 0, 0, 0.5); 199 + z-index: 200; 200 + opacity: 0; 201 + visibility: hidden; 202 + transition: 203 + opacity 0.2s ease, 204 + visibility 0.2s ease; 205 + } 206 + 207 + .menu-overlay.open { 208 + opacity: 1; 209 + visibility: visible; 210 + } 211 + 212 + .side-menu { 213 + position: fixed; 214 + top: 0; 215 + left: 0; 216 + width: 280px; 217 + max-width: 80%; 218 + height: 100%; 219 + background-color: var(--surface); 220 + z-index: 201; 221 + transform: translateX(-100%); 222 + transition: transform 0.3s ease; 223 + overflow-y: auto; 224 + } 225 + 226 + .side-menu.open { 227 + transform: translateX(0); 228 + } 229 + 230 + .menu-header { 231 + padding: 16px; 232 + border-bottom: 1px solid var(--border); 233 + display: flex; 234 + align-items: center; 235 + justify-content: space-between; 236 + } 237 + 238 + .menu-close { 239 + width: 36px; 240 + height: 36px; 241 + border-radius: 50%; 242 + border: none; 243 + background: transparent; 244 + color: var(--text-secondary); 245 + cursor: pointer; 246 + display: flex; 247 + align-items: center; 248 + justify-content: center; 249 + transition: background-color 0.2s ease; 250 + } 251 + 252 + .menu-close:hover { 253 + background-color: var(--surface-variant); 254 + } 255 + 256 + .menu-close svg { 257 + width: 24px; 258 + height: 24px; 259 + } 260 + 261 + .menu-user { 262 + padding: 16px; 263 + border-bottom: 1px solid var(--border); 264 + display: flex; 265 + align-items: center; 266 + gap: 12px; 267 + } 268 + 269 + .menu-user-avatar { 270 + width: 48px; 271 + height: 48px; 272 + border-radius: 50%; 273 + background-color: var(--surface-variant); 274 + display: flex; 275 + align-items: center; 276 + justify-content: center; 277 + font-weight: 600; 278 + color: var(--text-primary); 279 + } 280 + 281 + .menu-user-info { 282 + flex: 1; 283 + } 284 + 285 + .menu-user-name { 286 + font-weight: 600; 287 + color: var(--text-primary); 288 + font-size: 15px; 289 + } 290 + 291 + .menu-user-handle { 292 + color: var(--text-secondary); 293 + font-size: 14px; 294 + } 295 + 296 + .menu-nav { 297 + padding: 8px 0; 298 + } 299 + 300 + .menu-nav-item { 301 + display: flex; 302 + align-items: center; 303 + gap: 12px; 304 + padding: 14px 16px; 305 + color: var(--text-primary); 306 + text-decoration: none; 307 + transition: background-color 0.2s ease; 308 + } 309 + 310 + .menu-nav-item:hover { 311 + background-color: var(--surface-variant); 312 + } 313 + 314 + .menu-nav-item svg { 315 + width: 22px; 316 + height: 22px; 317 + color: var(--text-secondary); 318 + } 319 + 320 + .menu-nav-item span { 321 + font-size: 15px; 322 + font-weight: 500; 323 + } 324 + 325 + .menu-divider { 326 + height: 1px; 327 + background-color: var(--border); 328 + margin: 8px 16px; 329 + } 330 + 331 + .header-menu-btn { 332 + width: 36px; 333 + height: 36px; 334 + border-radius: 50%; 335 + border: none; 336 + background: transparent; 337 + color: var(--accent-primary); 338 + cursor: pointer; 339 + display: flex; 340 + align-items: center; 341 + justify-content: center; 342 + transition: background-color 0.2s ease; 343 + } 344 + 345 + .header-menu-btn:hover { 346 + background-color: rgba(0, 102, 255, 0.1); 347 + } 348 + 349 + .header-menu-btn svg { 350 + width: 24px; 351 + height: 24px; 352 + } 353 + </style> 354 + </head> 355 + <body> 356 + <div class="mobile-container"> 357 + <!-- Menu Overlay --> 358 + <div class="menu-overlay" id="menuOverlay" onclick="closeMenu()"></div> 359 + 360 + <!-- Side Menu --> 361 + <nav class="side-menu" id="sideMenu"> 362 + <div class="menu-header"> 363 + <span style="font-weight: 600; color: var(--text-primary)">Menu</span> 364 + <button class="menu-close" onclick="closeMenu()"> 365 + <svg 366 + viewBox="0 0 24 24" 367 + fill="none" 368 + stroke="currentColor" 369 + stroke-width="2" 370 + stroke-linecap="round" 371 + stroke-linejoin="round"> 372 + <line x1="18" y1="6" x2="6" y2="18"></line> 373 + <line x1="6" y1="6" x2="18" y2="18"></line> 374 + </svg> 375 + </button> 376 + </div> 377 + 378 + <a href="profile.html" class="menu-user"> 379 + <div class="menu-user-avatar">JD</div> 380 + <div class="menu-user-info"> 381 + <div class="menu-user-name">John Doe</div> 382 + <div class="menu-user-handle">@johndoe.bsky.social</div> 383 + </div> 384 + </a> 385 + 386 + <div class="menu-nav"> 387 + <a href="home.html" class="menu-nav-item"> 388 + <svg 389 + viewBox="0 0 24 24" 390 + fill="none" 391 + stroke="currentColor" 392 + stroke-width="2" 393 + stroke-linecap="round" 394 + stroke-linejoin="round"> 395 + <path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path> 396 + <polyline points="9 22 9 12 15 12 15 22"></polyline> 397 + </svg> 398 + <span>Home</span> 399 + </a> 400 + 401 + <a href="search.html" class="menu-nav-item"> 402 + <svg 403 + viewBox="0 0 24 24" 404 + fill="none" 405 + stroke="currentColor" 406 + stroke-width="2" 407 + stroke-linecap="round" 408 + stroke-linejoin="round"> 409 + <circle cx="11" cy="11" r="8"></circle> 410 + <line x1="21" y1="21" x2="16.65" y2="16.65"></line> 411 + </svg> 412 + <span>Search</span> 413 + </a> 414 + 415 + <a href="feeds.html" class="menu-nav-item"> 416 + <svg 417 + viewBox="0 0 24 24" 418 + fill="none" 419 + stroke="currentColor" 420 + stroke-width="2" 421 + stroke-linecap="round" 422 + stroke-linejoin="round"> 423 + <path d="M4 11a9 9 0 0 1 9 9"></path> 424 + <path d="M4 4a16 16 0 0 1 16 16"></path> 425 + <circle cx="5" cy="19" r="1"></circle> 426 + </svg> 427 + <span>Feeds</span> 428 + </a> 429 + 430 + <a href="notifications.html" class="menu-nav-item"> 431 + <svg 432 + viewBox="0 0 24 24" 433 + fill="none" 434 + stroke="currentColor" 435 + stroke-width="2" 436 + stroke-linecap="round" 437 + stroke-linejoin="round"> 438 + <path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"></path> 439 + <path d="M13.73 21a2 2 0 0 1-3.46 0"></path> 440 + </svg> 441 + <span>Notifications</span> 442 + </a> 443 + 444 + <a href="messages.html" class="menu-nav-item"> 445 + <svg 446 + viewBox="0 0 24 24" 447 + fill="none" 448 + stroke="currentColor" 449 + stroke-width="2" 450 + stroke-linecap="round" 451 + stroke-linejoin="round"> 452 + <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path> 453 + </svg> 454 + <span>Messages</span> 455 + </a> 456 + 457 + <a href="profile.html" class="menu-nav-item"> 458 + <svg 459 + viewBox="0 0 24 24" 460 + fill="none" 461 + stroke="currentColor" 462 + stroke-width="2" 463 + stroke-linecap="round" 464 + stroke-linejoin="round"> 465 + <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path> 466 + <circle cx="12" cy="7" r="4"></circle> 467 + </svg> 468 + <span>Profile</span> 469 + </a> 470 + 471 + <div class="menu-divider"></div> 472 + 473 + <a href="compose.html" class="menu-nav-item"> 474 + <svg 475 + viewBox="0 0 24 24" 476 + fill="none" 477 + stroke="currentColor" 478 + stroke-width="2" 479 + stroke-linecap="round" 480 + stroke-linejoin="round"> 481 + <line x1="12" y1="5" x2="12" y2="19"></line> 482 + <line x1="5" y1="12" x2="19" y2="12"></line> 483 + </svg> 484 + <span>New Post</span> 485 + </a> 486 + 487 + <a href="settings.html" class="menu-nav-item"> 488 + <svg 489 + viewBox="0 0 24 24" 490 + fill="none" 491 + stroke="currentColor" 492 + stroke-width="2" 493 + stroke-linecap="round" 494 + stroke-linejoin="round"> 495 + <circle cx="12" cy="12" r="3"></circle> 496 + <path 497 + d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path> 498 + </svg> 499 + <span>Settings</span> 500 + </a> 501 + 502 + <div class="menu-divider"></div> 503 + 504 + <a href="login.html" class="menu-nav-item" style="color: var(--accent-error);" onclick="logout(event)"> 505 + <svg 506 + viewBox="0 0 24 24" 507 + fill="none" 508 + stroke="currentColor" 509 + stroke-width="2" 510 + stroke-linecap="round" 511 + stroke-linejoin="round"> 512 + <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path> 513 + <polyline points="16 17 21 12 16 7"></polyline> 514 + <line x1="21" y1="12" x2="9" y2="12"></line> 515 + </svg> 516 + <span>Log Out</span> 517 + </a> 518 + </div> 519 + </nav> 520 + 521 + <!-- Header --> 522 + <header class="header"> 523 + <button class="header-menu-btn" onclick="openMenu()"> 524 + <svg 525 + viewBox="0 0 24 24" 526 + fill="none" 527 + stroke="currentColor" 528 + stroke-width="2" 529 + stroke-linecap="round" 530 + stroke-linejoin="round"> 531 + <line x1="3" y1="12" x2="21" y2="12"></line> 532 + <line x1="3" y1="6" x2="21" y2="6"></line> 533 + <line x1="3" y1="18" x2="21" y2="18"></line> 534 + </svg> 535 + </button> 536 + <h1 class="header-title">Home</h1> 537 + <button class="header-action">Settings</button> 538 + </header> 539 + 540 + <div class="feed-container"> 541 + <!-- Compose Box --> 542 + <div class="compose-box"> 543 + <div class="compose-inner"> 544 + <div class="avatar avatar-sm">JD</div> 545 + <div class="compose-input-wrapper"> 546 + <textarea class="compose-input" placeholder="What's on your mind?" rows="2"></textarea> 547 + </div> 548 + </div> 549 + <div class="compose-actions"> 550 + <div class="compose-tools"> 551 + <button class="compose-tool" title="Add image"> 552 + <svg 553 + viewBox="0 0 24 24" 554 + fill="none" 555 + stroke="currentColor" 556 + stroke-width="2" 557 + stroke-linecap="round" 558 + stroke-linejoin="round"> 559 + <rect x="3" y="3" width="18" height="18" rx="2" ry="2" /> 560 + <circle cx="8.5" cy="8.5" r="1.5" /> 561 + <polyline points="21 15 16 10 5 21" /> 562 + </svg> 563 + </button> 564 + <button class="compose-tool" title="Add GIF"> 565 + <svg 566 + viewBox="0 0 24 24" 567 + fill="none" 568 + stroke="currentColor" 569 + stroke-width="2" 570 + stroke-linecap="round" 571 + stroke-linejoin="round"> 572 + <text x="4" y="16" font-family="inherit" font-size="12" font-weight="bold" fill="currentColor"> 573 + GIF 574 + </text> 575 + </svg> 576 + </button> 577 + <button class="compose-tool" title="Add poll"> 578 + <svg 579 + viewBox="0 0 24 24" 580 + fill="none" 581 + stroke="currentColor" 582 + stroke-width="2" 583 + stroke-linecap="round" 584 + stroke-linejoin="round"> 585 + <line x1="18" y1="20" x2="18" y2="10" /> 586 + <line x1="12" y1="20" x2="12" y2="4" /> 587 + <line x1="6" y1="20" x2="6" y2="14" /> 588 + </svg> 589 + </button> 590 + <button class="compose-tool" title="Emoji"> 591 + <svg 592 + viewBox="0 0 24 24" 593 + fill="none" 594 + stroke="currentColor" 595 + stroke-width="2" 596 + stroke-linecap="round" 597 + stroke-linejoin="round"> 598 + <circle cx="12" cy="12" r="10" /> 599 + <path d="M8 14s1.5 2 4 2 4-2 4-2" /> 600 + <line x1="9" y1="9" x2="9.01" y2="9" /> 601 + <line x1="15" y1="9" x2="15.01" y2="9" /> 602 + </svg> 603 + </button> 604 + </div> 605 + <button class="compose-submit">Post</button> 606 + </div> 607 + </div> 608 + 609 + <!-- Feed Tabs --> 610 + <div class="feed-tabs"> 611 + <button class="feed-tab active">Following</button> 612 + <button class="feed-tab">Discover</button> 613 + </div> 614 + 615 + <!-- Post 1 --> 616 + <article class="post-card"> 617 + <div class="post-header"> 618 + <div class="avatar">AS</div> 619 + <div class="post-author"> 620 + <div class="post-author-name">Alice Smith</div> 621 + <div class="post-author-handle">@alice.bsky.social · <span class="post-timestamp">2h</span></div> 622 + </div> 623 + </div> 624 + 625 + <div class="post-content"> 626 + Just launched my new project! 🚀 Check out the demo at <a href="#" class="post-facet-link">example.com</a>. 627 + Special thanks to <a href="#" class="post-facet-mention">@bob.bsky.social</a> for the help! 628 + <a href="#" class="post-facet-hashtag">#buildinpublic</a> <a href="#" class="post-facet-hashtag">#dev</a> 629 + </div> 630 + 631 + <div class="post-embed"> 632 + <div class="post-embed-image">[Project Screenshot]</div> 633 + <div class="post-embed-content"> 634 + <div class="post-embed-title">My Awesome Project</div> 635 + <div class="post-embed-url">example.com</div> 636 + </div> 637 + </div> 638 + 639 + <div class="post-actions"> 640 + <button class="post-action"> 641 + <svg 642 + viewBox="0 0 24 24" 643 + fill="none" 644 + stroke="currentColor" 645 + stroke-width="2" 646 + stroke-linecap="round" 647 + stroke-linejoin="round"> 648 + <path 649 + d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" /> 650 + </svg> 651 + 12 652 + </button> 653 + <button class="post-action"> 654 + <svg 655 + viewBox="0 0 24 24" 656 + fill="none" 657 + stroke="currentColor" 658 + stroke-width="2" 659 + stroke-linecap="round" 660 + stroke-linejoin="round"> 661 + <polyline points="17 1 21 5 17 9" /> 662 + <path d="M3 11V9a4 4 0 0 1 4-4h14" /> 663 + <polyline points="7 23 3 19 7 15" /> 664 + <path d="M21 13v2a4 4 0 0 1-4 4H3" /> 665 + </svg> 666 + 5 667 + </button> 668 + <button class="post-action"> 669 + <svg 670 + viewBox="0 0 24 24" 671 + fill="none" 672 + stroke="currentColor" 673 + stroke-width="2" 674 + stroke-linecap="round" 675 + stroke-linejoin="round"> 676 + <path 677 + d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" /> 678 + </svg> 679 + 48 680 + </button> 681 + <button class="post-action"> 682 + <svg 683 + viewBox="0 0 24 24" 684 + fill="none" 685 + stroke="currentColor" 686 + stroke-width="2" 687 + stroke-linecap="round" 688 + stroke-linejoin="round"> 689 + <path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" /> 690 + <polyline points="16 6 12 2 8 6" /> 691 + <line x1="12" y1="2" x2="12" y2="15" /> 692 + </svg> 693 + </button> 694 + </div> 695 + </article> 696 + 697 + <!-- Post 2 --> 698 + <article class="post-card"> 699 + <div class="post-header"> 700 + <div class="avatar">BJ</div> 701 + <div class="post-author"> 702 + <div class="post-author-name">Bob Johnson</div> 703 + <div class="post-author-handle">@bob.bsky.social · <span class="post-timestamp">4h</span></div> 704 + </div> 705 + </div> 706 + 707 + <div class="post-content"> 708 + Working on some exciting features for the next release! Here's a sneak peek of what's coming: improved 709 + search, better notifications, and dark mode support. <a href="#" class="post-facet-hashtag">#bluesky</a> 710 + <a href="#" class="post-facet-hashtag">#atproto</a> 711 + </div> 712 + 713 + <div class="post-actions"> 714 + <button class="post-action"> 715 + <svg 716 + viewBox="0 0 24 24" 717 + fill="none" 718 + stroke="currentColor" 719 + stroke-width="2" 720 + stroke-linecap="round" 721 + stroke-linejoin="round"> 722 + <path 723 + d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" /> 724 + </svg> 725 + 8 726 + </button> 727 + <button class="post-action"> 728 + <svg 729 + viewBox="0 0 24 24" 730 + fill="none" 731 + stroke="currentColor" 732 + stroke-width="2" 733 + stroke-linecap="round" 734 + stroke-linejoin="round"> 735 + <polyline points="17 1 21 5 17 9" /> 736 + <path d="M3 11V9a4 4 0 0 1 4-4h14" /> 737 + <polyline points="7 23 3 19 7 15" /> 738 + <path d="M21 13v2a4 4 0 0 1-4 4H3" /> 739 + </svg> 740 + 24 741 + </button> 742 + <button class="post-action"> 743 + <svg 744 + viewBox="0 0 24 24" 745 + fill="none" 746 + stroke="currentColor" 747 + stroke-width="2" 748 + stroke-linecap="round" 749 + stroke-linejoin="round"> 750 + <path 751 + d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" /> 752 + </svg> 753 + 156 754 + </button> 755 + <button class="post-action"> 756 + <svg 757 + viewBox="0 0 24 24" 758 + fill="none" 759 + stroke="currentColor" 760 + stroke-width="2" 761 + stroke-linecap="round" 762 + stroke-linejoin="round"> 763 + <path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" /> 764 + <polyline points="16 6 12 2 8 6" /> 765 + <line x1="12" y1="2" x2="12" y2="15" /> 766 + </svg> 767 + </button> 768 + </div> 769 + </article> 770 + 771 + <!-- Post 3 - Rich Facets --> 772 + <article class="post-card"> 773 + <div class="post-header"> 774 + <div class="avatar">CW</div> 775 + <div class="post-author"> 776 + <div class="post-author-name">Carol White</div> 777 + <div class="post-author-handle">@carol.dev · <span class="post-timestamp">6h</span></div> 778 + </div> 779 + </div> 780 + 781 + <div class="post-content"> 782 + The <a href="#" class="post-facet-mention">@atproto</a> team has been doing amazing work! Read their latest 783 + blog post about federation: <a href="#" class="post-facet-link">atproto.com/blog/federation</a> 784 + 785 + <br /><br /> 786 + 787 + Key highlights: • Self-hosting guides • PDS (Personal Data Server) setup • Relay infrastructure 788 + 789 + <br /><br /> 790 + 791 + <a href="#" class="post-facet-hashtag">#atprotocol</a> 792 + <a href="#" class="post-facet-hashtag">#decentralized</a> 793 + <a href="#" class="post-facet-hashtag">#socialweb</a> 794 + </div> 795 + 796 + <div class="post-actions"> 797 + <button class="post-action"> 798 + <svg 799 + viewBox="0 0 24 24" 800 + fill="none" 801 + stroke="currentColor" 802 + stroke-width="2" 803 + stroke-linecap="round" 804 + stroke-linejoin="round"> 805 + <path 806 + d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" /> 807 + </svg> 808 + 34 809 + </button> 810 + <button class="post-action"> 811 + <svg 812 + viewBox="0 0 24 24" 813 + fill="none" 814 + stroke="currentColor" 815 + stroke-width="2" 816 + stroke-linecap="round" 817 + stroke-linejoin="round"> 818 + <polyline points="17 1 21 5 17 9" /> 819 + <path d="M3 11V9a4 4 0 0 1 4-4h14" /> 820 + <polyline points="7 23 3 19 7 15" /> 821 + <path d="M21 13v2a4 4 0 0 1-4 4H3" /> 822 + </svg> 823 + 12 824 + </button> 825 + <button class="post-action"> 826 + <svg 827 + viewBox="0 0 24 24" 828 + fill="none" 829 + stroke="currentColor" 830 + stroke-width="2" 831 + stroke-linecap="round" 832 + stroke-linejoin="round"> 833 + <path 834 + d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" /> 835 + </svg> 836 + 289 837 + </button> 838 + <button class="post-action"> 839 + <svg 840 + viewBox="0 0 24 24" 841 + fill="none" 842 + stroke="currentColor" 843 + stroke-width="2" 844 + stroke-linecap="round" 845 + stroke-linejoin="round"> 846 + <path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" /> 847 + <polyline points="16 6 12 2 8 6" /> 848 + <line x1="12" y1="2" x2="12" y2="15" /> 849 + </svg> 850 + </button> 851 + </div> 852 + </article> 853 + 854 + <!-- Post 4 --> 855 + <article class="post-card"> 856 + <div class="post-header"> 857 + <div class="avatar">DM</div> 858 + <div class="post-author"> 859 + <div class="post-author-name">David Miller</div> 860 + <div class="post-author-handle">@davidm.bsky.social · <span class="post-timestamp">8h</span></div> 861 + </div> 862 + </div> 863 + 864 + <div class="post-content"> 865 + Beautiful sunset from my hike today! 🌅 <a href="#" class="post-facet-hashtag">#nature</a> 866 + <a href="#" class="post-facet-hashtag">#photography</a> <a href="#" class="post-facet-hashtag">#hiking</a> 867 + </div> 868 + 869 + <div class="post-embed"> 870 + <div class="post-embed-image">[Sunset Photo]</div> 871 + </div> 872 + 873 + <div class="post-actions"> 874 + <button class="post-action"> 875 + <svg 876 + viewBox="0 0 24 24" 877 + fill="none" 878 + stroke="currentColor" 879 + stroke-width="2" 880 + stroke-linecap="round" 881 + stroke-linejoin="round"> 882 + <path 883 + d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" /> 884 + </svg> 885 + 6 886 + </button> 887 + <button class="post-action"> 888 + <svg 889 + viewBox="0 0 24 24" 890 + fill="none" 891 + stroke="currentColor" 892 + stroke-width="2" 893 + stroke-linecap="round" 894 + stroke-linejoin="round"> 895 + <polyline points="17 1 21 5 17 9" /> 896 + <path d="M3 11V9a4 4 0 0 1 4-4h14" /> 897 + <polyline points="7 23 3 19 7 15" /> 898 + <path d="M21 13v2a4 4 0 0 1-4 4H3" /> 899 + </svg> 900 + 18 901 + </button> 902 + <button class="post-action"> 903 + <svg 904 + viewBox="0 0 24 24" 905 + fill="none" 906 + stroke="currentColor" 907 + stroke-width="2" 908 + stroke-linecap="round" 909 + stroke-linejoin="round"> 910 + <path 911 + d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" /> 912 + </svg> 913 + 423 914 + </button> 915 + <button class="post-action"> 916 + <svg 917 + viewBox="0 0 24 24" 918 + fill="none" 919 + stroke="currentColor" 920 + stroke-width="2" 921 + stroke-linecap="round" 922 + stroke-linejoin="round"> 923 + <path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" /> 924 + <polyline points="16 6 12 2 8 6" /> 925 + <line x1="12" y1="2" x2="12" y2="15" /> 926 + </svg> 927 + </button> 928 + </div> 929 + </article> 930 + </div> 931 + 932 + <!-- Bottom Navigation --> 933 + <nav class="nav-bar"> 934 + <a href="home.html" class="nav-item active"> 935 + <svg 936 + viewBox="0 0 24 24" 937 + fill="none" 938 + stroke="currentColor" 939 + stroke-width="2" 940 + stroke-linecap="round" 941 + stroke-linejoin="round"> 942 + <path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" /> 943 + <polyline points="9 22 9 12 15 12 15 22" /> 944 + </svg> 945 + <span>Home</span> 946 + </a> 947 + 948 + <a href="profile.html" class="nav-item"> 949 + <svg 950 + viewBox="0 0 24 24" 951 + fill="none" 952 + stroke="currentColor" 953 + stroke-width="2" 954 + stroke-linecap="round" 955 + stroke-linejoin="round"> 956 + <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" /> 957 + <circle cx="12" cy="7" r="4" /> 958 + </svg> 959 + <span>Profile</span> 960 + </a> 961 + 962 + <a href="settings.html" class="nav-item"> 963 + <svg 964 + viewBox="0 0 24 24" 965 + fill="none" 966 + stroke="currentColor" 967 + stroke-width="2" 968 + stroke-linecap="round" 969 + stroke-linejoin="round"> 970 + <circle cx="12" cy="12" r="3" /> 971 + <path 972 + d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" /> 973 + </svg> 974 + <span>Settings</span> 975 + </a> 976 + </nav> 977 + </div> 978 + 979 + <script> 980 + function openMenu() { 981 + document.getElementById("menuOverlay").classList.add("open"); 982 + document.getElementById("sideMenu").classList.add("open"); 983 + } 984 + 985 + function closeMenu() { 986 + document.getElementById("menuOverlay").classList.remove("open"); 987 + document.getElementById("sideMenu").classList.remove("open"); 988 + } 989 + 990 + function logout(e) { 991 + e.preventDefault(); 992 + if (confirm("Are you sure you want to log out?")) { 993 + localStorage.clear(); 994 + window.location.href = "login.html"; 995 + } 996 + } 997 + 998 + if (localStorage.getItem("theme") === "dark") { 999 + document.documentElement.setAttribute("data-theme", "dark"); 1000 + } 1001 + </script> 1002 + </body> 1003 + </html>
+4 -4
docs/tasks/phase-4.md
··· 2 2 3 3 ## M12 — Direct Messages 4 4 5 - - [ ] Conversation list screen via `chat.bsky.convo.listConvos` with pagination 5 + - [x] Conversation list screen via `chat.bsky.convo.listConvos` with pagination 6 6 - [x] `ConvoListBloc` — events: `ConvosRequested`, `ConvosRefreshed`, `ConvoMuted`, `ConvoUnmuted` 7 7 - [x] Primary / Requests tab filtering on conversation list 8 - - [ ] Message thread screen via `chat.bsky.convo.getMessages` with pagination 8 + - [x] Message thread screen via `chat.bsky.convo.getMessages` with pagination 9 9 - [x] `MessageBloc` — events: `MessagesRequested`, `MessagesPageLoaded`, `MessageSent`, `MessageDeleted`, `ConvoMarkedRead` 10 - - [ ] Chat bubble layout — current user right-aligned, others left-aligned 10 + - [x] Chat bubble layout — current user right-aligned, others left-aligned 11 11 - [x] Send messages via `chat.bsky.convo.sendMessage` 12 12 - [x] New conversation via `chat.bsky.convo.getConvoForMembers` 13 - - [ ] Long-press to copy individual messages, overflow menu "Copy All" for full thread 13 + - [x] Long-press to copy individual messages, overflow menu "Copy All" for full thread 14 14 - [x] Mute / unmute conversations 15 15 - [x] Mark conversation as read via `chat.bsky.convo.updateRead` 16 16
+6
ios/Podfile.lock
··· 1 1 PODS: 2 + - connectivity_plus (0.0.1): 3 + - Flutter 2 4 - Flutter (1.0.0) 3 5 - image_picker_ios (0.0.1): 4 6 - Flutter ··· 38 40 - Flutter 39 41 40 42 DEPENDENCIES: 43 + - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) 41 44 - Flutter (from `Flutter`) 42 45 - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) 43 46 - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) ··· 51 54 - sqlite3 52 55 53 56 EXTERNAL SOURCES: 57 + connectivity_plus: 58 + :path: ".symlinks/plugins/connectivity_plus/ios" 54 59 Flutter: 55 60 :path: Flutter 56 61 image_picker_ios: ··· 67 72 :path: ".symlinks/plugins/workmanager/ios" 68 73 69 74 SPEC CHECKSUMS: 75 + connectivity_plus: 2a701ffec2c0ae28a48cf7540e279787e77c447d 70 76 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 71 77 image_picker_ios: 4f2f91b01abdb52842a8e277617df877e40f905b 72 78 path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba
+32
lib/core/network/xrpc_client_factory.dart
··· 2 2 import 'package:atproto_core/atproto_core.dart' as atp_core; 3 3 import 'package:atproto_oauth/atproto_oauth.dart' as atp_oauth; 4 4 import 'package:bluesky/bluesky.dart'; 5 + import 'package:bluesky/bluesky_chat.dart'; 5 6 import 'package:lazurite/features/auth/data/models/auth_models.dart'; 6 7 7 8 /// Creates a Bluesky client from authentication tokens. ··· 41 42 ); 42 43 43 44 return Bluesky.fromSession(session, service: tokens.service); 45 + } 46 + 47 + BlueskyChat? createBlueSkyChatClient(AuthTokens? tokens) { 48 + if (tokens == null) return null; 49 + 50 + if (tokens.usesOAuth) { 51 + if (tokens.dpopPublicKey == null || tokens.dpopPrivateKey == null || tokens.refreshToken == null) { 52 + return null; 53 + } 54 + 55 + final oauthSession = atp_core.restoreOAuthSession( 56 + accessToken: tokens.accessToken, 57 + refreshToken: tokens.refreshToken!, 58 + dPoPNonce: tokens.dpopNonce, 59 + publicKey: tokens.dpopPublicKey!, 60 + privateKey: tokens.dpopPrivateKey!, 61 + ); 62 + 63 + return BlueskyChat.fromOAuthSession(oauthSession); 64 + } 65 + 66 + if (tokens.refreshToken == null) return null; 67 + 68 + final session = atp_core.Session( 69 + did: tokens.did, 70 + handle: tokens.handle, 71 + accessJwt: tokens.accessToken, 72 + refreshJwt: tokens.refreshToken!, 73 + ); 74 + 75 + return BlueskyChat.fromSession(session, service: tokens.service); 44 76 } 45 77 46 78 atp.ATProto createAtProtoForOAuthSession(atp_oauth.OAuthSession session) {
+35
lib/core/router/app_router.dart
··· 24 24 import 'package:lazurite/features/profile/presentation/profile_screen.dart'; 25 25 import 'package:lazurite/features/feed/presentation/saved_posts_screen.dart'; 26 26 import 'package:lazurite/features/search/presentation/search_screen.dart'; 27 + import 'package:lazurite/features/messages/bloc/convo_list_bloc.dart'; 28 + import 'package:lazurite/features/messages/bloc/message_bloc.dart'; 29 + import 'package:lazurite/features/messages/data/convo_repository.dart'; 30 + import 'package:lazurite/features/messages/presentation/convo_list_screen.dart'; 31 + import 'package:lazurite/features/messages/presentation/message_thread_route_args.dart'; 32 + import 'package:lazurite/features/messages/presentation/message_thread_screen.dart'; 27 33 import 'package:lazurite/features/settings/presentation/about_screen.dart'; 28 34 import 'package:lazurite/features/settings/presentation/settings_screen.dart'; 29 35 ··· 36 42 final GlobalKey<NavigatorState> _searchNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'search'); 37 43 final GlobalKey<NavigatorState> _notificationsNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'notifications'); 38 44 final GlobalKey<NavigatorState> _profileNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'profile'); 45 + final GlobalKey<NavigatorState> _messagesNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'messages'); 39 46 final GlobalKey<NavigatorState> _settingsNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'settings'); 40 47 41 48 GoRouter get router => GoRouter( ··· 158 165 path: 'view', 159 166 builder: (context, state) => 160 167 ProfileScreen(actor: state.uri.queryParameters['actor'], showBackButton: true), 168 + ), 169 + ], 170 + ), 171 + ], 172 + ), 173 + StatefulShellBranch( 174 + navigatorKey: _messagesNavigatorKey, 175 + routes: [ 176 + GoRoute( 177 + path: '/messages', 178 + builder: (context, state) => BlocProvider( 179 + create: (_) => ConvoListBloc(convoRepository: context.read<ConvoRepository>()), 180 + child: const ConvoListScreen(), 181 + ), 182 + routes: [ 183 + GoRoute( 184 + path: ':id', 185 + builder: (context, state) { 186 + final convoId = state.pathParameters['id']!; 187 + final args = state.extra as MessageThreadRouteArgs?; 188 + return BlocProvider( 189 + create: (_) => MessageBloc( 190 + convoRepository: context.read<ConvoRepository>(), 191 + currentUserDid: context.read<String>(), 192 + ), 193 + child: MessageThreadScreen(convoId: convoId, title: args?.title ?? 'Conversation'), 194 + ); 195 + }, 161 196 ), 162 197 ], 163 198 ),
+5
lib/core/router/app_shell.dart
··· 52 52 ), 53 53 const NavigationDestination(icon: Icon(Icons.person_outline), selectedIcon: Icon(Icons.person), label: 'Profile'), 54 54 const NavigationDestination( 55 + icon: Icon(Icons.chat_bubble_outline), 56 + selectedIcon: Icon(Icons.chat_bubble), 57 + label: 'Messages', 58 + ), 59 + const NavigationDestination( 55 60 icon: Icon(Icons.settings_outlined), 56 61 selectedIcon: Icon(Icons.settings), 57 62 label: 'Settings',
+5
lib/features/messages/bloc/convo_list_bloc.dart
··· 15 15 on<ConvosRefreshed>(_onConvosRefreshed); 16 16 on<ConvoMuted>(_onConvoMuted); 17 17 on<ConvoUnmuted>(_onConvoUnmuted); 18 + on<ConvoTabChanged>(_onConvoTabChanged); 18 19 } 19 20 20 21 final ConvoRepository _convoRepository; ··· 72 73 } catch (error) { 73 74 log.w('Failed to unmute conversation ${event.convoId}: $error'); 74 75 } 76 + } 77 + 78 + void _onConvoTabChanged(ConvoTabChanged event, Emitter<ConvoListState> emit) { 79 + emit(state.copyWith(activeTab: event.tab)); 75 80 } 76 81 }
+9
lib/features/messages/bloc/convo_list_event.dart
··· 37 37 @override 38 38 List<Object?> get props => [convoId]; 39 39 } 40 + 41 + class ConvoTabChanged extends ConvoListEvent { 42 + const ConvoTabChanged({required this.tab}); 43 + 44 + final ConvoTab tab; 45 + 46 + @override 47 + List<Object?> get props => [tab]; 48 + }
+4 -1
lib/features/messages/bloc/message_bloc.dart
··· 20 20 } 21 21 22 22 final ConvoRepository _convoRepository; 23 - // FIXME: use this 24 23 final String _currentUserDid; 24 + 25 + String get currentUserDid => _currentUserDid; 25 26 26 27 Future<void> _onMessagesRequested(MessagesRequested event, Emitter<MessageState> emit) async { 27 28 emit(const MessageState.loading()); ··· 39 40 ); 40 41 } catch (error) { 41 42 emit(MessageState.error('Failed to load messages: $error')); 43 + log.e('Failed to load messages: $error'); 42 44 } 43 45 } 44 46 ··· 62 64 ); 63 65 } catch (error) { 64 66 emit(state.copyWith(isLoadingMore: false, hasMore: false)); 67 + log.e('Failed to load more messages: $error'); 65 68 } 66 69 } 67 70
+162
lib/features/messages/presentation/convo_list_screen.dart
··· 1 + import 'package:bluesky/chat_bsky_convo_defs.dart'; 2 + import 'package:flutter/material.dart'; 3 + import 'package:flutter_bloc/flutter_bloc.dart'; 4 + import 'package:go_router/go_router.dart'; 5 + import 'package:lazurite/core/logging/app_logger.dart'; 6 + import 'package:lazurite/features/messages/bloc/convo_list_bloc.dart'; 7 + import 'package:lazurite/features/messages/presentation/message_thread_route_args.dart'; 8 + import 'package:lazurite/features/messages/presentation/widgets/convo_list_item.dart'; 9 + 10 + class ConvoListScreen extends StatefulWidget { 11 + const ConvoListScreen({super.key}); 12 + 13 + @override 14 + State<ConvoListScreen> createState() => _ConvoListScreenState(); 15 + } 16 + 17 + class _ConvoListScreenState extends State<ConvoListScreen> with SingleTickerProviderStateMixin { 18 + late final TabController _tabController; 19 + final ScrollController _scrollController = ScrollController(); 20 + 21 + @override 22 + void initState() { 23 + super.initState(); 24 + _tabController = TabController(length: 2, vsync: this); 25 + _tabController.addListener(_onTabChanged); 26 + _scrollController.addListener(_onScroll); 27 + context.read<ConvoListBloc>().add(const ConvosRequested()); 28 + } 29 + 30 + @override 31 + void dispose() { 32 + _tabController 33 + ..removeListener(_onTabChanged) 34 + ..dispose(); 35 + _scrollController 36 + ..removeListener(_onScroll) 37 + ..dispose(); 38 + super.dispose(); 39 + } 40 + 41 + void _onTabChanged() { 42 + if (_tabController.indexIsChanging) return; 43 + final tab = _tabController.index == 0 ? ConvoTab.primary : ConvoTab.requests; 44 + context.read<ConvoListBloc>().add(ConvoTabChanged(tab: tab)); 45 + } 46 + 47 + void _onScroll() { 48 + if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) { 49 + log.d('Pagination not yet implemented'); 50 + } 51 + } 52 + 53 + Future<void> _onRefresh() async => context.read<ConvoListBloc>().add(const ConvosRefreshed()); 54 + 55 + String _currentUserDid(BuildContext context) { 56 + try { 57 + return context.read<String>(); 58 + } catch (_) { 59 + return ''; 60 + } 61 + } 62 + 63 + @override 64 + Widget build(BuildContext context) { 65 + return Scaffold( 66 + appBar: AppBar( 67 + title: Text('Messages', style: Theme.of(context).textTheme.titleMedium), 68 + bottom: TabBar( 69 + controller: _tabController, 70 + tabs: const [ 71 + Tab(text: 'Primary'), 72 + Tab(text: 'Requests'), 73 + ], 74 + ), 75 + ), 76 + body: BlocBuilder<ConvoListBloc, ConvoListState>( 77 + builder: (context, state) { 78 + if (state.status == ConvoListStatus.initial || 79 + (state.status == ConvoListStatus.loading && state.convos.isEmpty)) { 80 + return const Center(child: CircularProgressIndicator()); 81 + } 82 + 83 + if (state.status == ConvoListStatus.error && state.convos.isEmpty) { 84 + return Center( 85 + child: Column( 86 + mainAxisAlignment: MainAxisAlignment.center, 87 + children: [ 88 + Text('Failed to load messages', style: Theme.of(context).textTheme.titleMedium), 89 + const SizedBox(height: 8), 90 + Text(state.errorMessage ?? 'Unknown error', textAlign: TextAlign.center), 91 + const SizedBox(height: 16), 92 + FilledButton( 93 + onPressed: () => context.read<ConvoListBloc>().add(const ConvosRequested()), 94 + child: const Text('Retry'), 95 + ), 96 + ], 97 + ), 98 + ); 99 + } 100 + 101 + final filtered = _filteredConvos(state.convos, state.activeTab); 102 + 103 + if (filtered.isEmpty) { 104 + return RefreshIndicator( 105 + onRefresh: _onRefresh, 106 + child: ListView( 107 + controller: _scrollController, 108 + children: [ 109 + SizedBox( 110 + height: MediaQuery.of(context).size.height * 0.5, 111 + child: Center( 112 + child: Text( 113 + state.activeTab == ConvoTab.primary ? 'No conversations yet' : 'No message requests', 114 + style: Theme.of(context).textTheme.bodyLarge, 115 + ), 116 + ), 117 + ), 118 + ], 119 + ), 120 + ); 121 + } 122 + 123 + final currentUserDid = _currentUserDid(context); 124 + 125 + return RefreshIndicator( 126 + onRefresh: _onRefresh, 127 + child: ListView.builder( 128 + controller: _scrollController, 129 + itemCount: filtered.length, 130 + itemBuilder: (context, index) { 131 + final convo = filtered[index]; 132 + return ConvoListItem( 133 + convo: convo, 134 + currentUserDid: currentUserDid, 135 + onTap: () => _openThread(context, convo, currentUserDid), 136 + onMuteTap: () { 137 + if (convo.muted) { 138 + context.read<ConvoListBloc>().add(ConvoUnmuted(convoId: convo.id)); 139 + } else { 140 + context.read<ConvoListBloc>().add(ConvoMuted(convoId: convo.id)); 141 + } 142 + }, 143 + ); 144 + }, 145 + ), 146 + ); 147 + }, 148 + ), 149 + ); 150 + } 151 + 152 + List<ConvoView> _filteredConvos(List<ConvoView> convos, ConvoTab tab) => convos.where((c) { 153 + final isRequest = c.status?.when(knownValue: (data) => data.value == 'request', unknown: (_) => false) ?? false; 154 + return tab == ConvoTab.requests ? isRequest : !isRequest; 155 + }).toList(); 156 + 157 + void _openThread(BuildContext context, ConvoView convo, String currentUserDid) { 158 + final other = convo.members.where((m) => m.did != currentUserDid).firstOrNull; 159 + final title = other?.displayName ?? other?.handle ?? 'Conversation'; 160 + context.push('/messages/${convo.id}', extra: MessageThreadRouteArgs(title: title)); 161 + } 162 + }
+5
lib/features/messages/presentation/message_thread_route_args.dart
··· 1 + class MessageThreadRouteArgs { 2 + const MessageThreadRouteArgs({required this.title}); 3 + 4 + final String title; 5 + }
+193
lib/features/messages/presentation/message_thread_screen.dart
··· 1 + import 'package:bluesky/chat_bsky_convo_getmessages.dart'; 2 + import 'package:flutter/material.dart'; 3 + import 'package:flutter/services.dart'; 4 + import 'package:flutter_bloc/flutter_bloc.dart'; 5 + import 'package:lazurite/features/messages/bloc/message_bloc.dart'; 6 + import 'package:lazurite/features/messages/presentation/widgets/message_bubble.dart'; 7 + 8 + class MessageThreadScreen extends StatefulWidget { 9 + const MessageThreadScreen({super.key, required this.convoId, required this.title}); 10 + 11 + final String convoId; 12 + final String title; 13 + 14 + @override 15 + State<MessageThreadScreen> createState() => _MessageThreadScreenState(); 16 + } 17 + 18 + class _MessageThreadScreenState extends State<MessageThreadScreen> { 19 + final ScrollController _scrollController = ScrollController(); 20 + final TextEditingController _textController = TextEditingController(); 21 + final FocusNode _focusNode = FocusNode(); 22 + 23 + @override 24 + void initState() { 25 + super.initState(); 26 + _scrollController.addListener(_onScroll); 27 + context.read<MessageBloc>() 28 + ..add(MessagesRequested(convoId: widget.convoId)) 29 + ..add(const ConvoMarkedRead()); 30 + } 31 + 32 + @override 33 + void dispose() { 34 + _scrollController 35 + ..removeListener(_onScroll) 36 + ..dispose(); 37 + _textController.dispose(); 38 + _focusNode.dispose(); 39 + super.dispose(); 40 + } 41 + 42 + void _onScroll() { 43 + if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) { 44 + context.read<MessageBloc>().add(const MessagesPageLoaded()); 45 + } 46 + } 47 + 48 + void _sendMessage() { 49 + final text = _textController.text.trim(); 50 + if (text.isEmpty) return; 51 + _textController.clear(); 52 + context.read<MessageBloc>().add(MessageSent(text: text)); 53 + } 54 + 55 + void _copyAllMessages(List<UConvoGetMessagesMessages> messages) { 56 + final lines = messages.reversed 57 + .map( 58 + (m) => m.when(messageView: (data) => data.text, deletedMessageView: (_) => '[deleted]', unknown: (_) => ''), 59 + ) 60 + .where((t) => t.isNotEmpty) 61 + .join('\n'); 62 + Clipboard.setData(ClipboardData(text: lines)); 63 + ScaffoldMessenger.of( 64 + context, 65 + ).showSnackBar(const SnackBar(content: Text('Thread copied'), duration: Duration(seconds: 2))); 66 + } 67 + 68 + ThemeData get _theme => Theme.of(context); 69 + 70 + @override 71 + Widget build(BuildContext context) { 72 + final currentUserDid = context.read<MessageBloc>().currentUserDid; 73 + 74 + return Scaffold( 75 + appBar: AppBar( 76 + title: Text(widget.title, style: _theme.textTheme.titleMedium), 77 + actions: [ 78 + BlocBuilder<MessageBloc, MessageState>( 79 + builder: (context, state) => PopupMenuButton<_ThreadAction>( 80 + onSelected: (action) { 81 + if (action == _ThreadAction.copyAll && state.messages.isNotEmpty) { 82 + _copyAllMessages(state.messages); 83 + } 84 + }, 85 + itemBuilder: (_) => const [PopupMenuItem(value: _ThreadAction.copyAll, child: Text('Copy All'))], 86 + ), 87 + ), 88 + ], 89 + ), 90 + body: Column( 91 + children: [ 92 + Expanded( 93 + child: BlocBuilder<MessageBloc, MessageState>( 94 + builder: (context, state) { 95 + if (state.status == MessageStatus.initial || 96 + (state.status == MessageStatus.loading && state.messages.isEmpty)) { 97 + return const Center(child: CircularProgressIndicator()); 98 + } 99 + 100 + if (state.status == MessageStatus.error && state.messages.isEmpty) { 101 + return _buildErrorWidget(); 102 + } 103 + 104 + if (state.messages.isEmpty) { 105 + return Center(child: Text('No messages yet', style: _theme.textTheme.bodyLarge)); 106 + } 107 + 108 + return ListView.builder( 109 + controller: _scrollController, 110 + reverse: true, 111 + itemCount: state.messages.length + (state.isLoadingMore ? 1 : 0), 112 + itemBuilder: (context, index) { 113 + if (index == state.messages.length) { 114 + return const Padding( 115 + padding: EdgeInsets.all(16), 116 + child: Center(child: CircularProgressIndicator()), 117 + ); 118 + } 119 + 120 + final message = state.messages[index]; 121 + return message.when( 122 + messageView: (data) => 123 + MessageBubble(message: data, isCurrentUser: data.sender.did == currentUserDid), 124 + deletedMessageView: (data) => const DeletedMessageBubble(isCurrentUser: false), 125 + unknown: (_) => const SizedBox.shrink(), 126 + ); 127 + }, 128 + ); 129 + }, 130 + ), 131 + ), 132 + const SizedBox(height: 16), 133 + _buildInputBar(context), 134 + ], 135 + ), 136 + ); 137 + } 138 + 139 + Widget _buildInputBar(BuildContext context) => SafeArea( 140 + child: Container( 141 + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), 142 + decoration: BoxDecoration( 143 + color: _theme.colorScheme.surface, 144 + border: Border(top: BorderSide(color: _theme.dividerColor)), 145 + ), 146 + child: Row( 147 + children: [ 148 + Expanded( 149 + child: TextField( 150 + controller: _textController, 151 + focusNode: _focusNode, 152 + minLines: 1, 153 + maxLines: 5, 154 + textCapitalization: TextCapitalization.sentences, 155 + decoration: InputDecoration( 156 + hintText: 'Message…', 157 + border: OutlineInputBorder(borderRadius: BorderRadius.circular(24)), 158 + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), 159 + isDense: true, 160 + ), 161 + onSubmitted: (_) => _sendMessage(), 162 + ), 163 + ), 164 + const SizedBox(width: 8), 165 + BlocBuilder<MessageBloc, MessageState>( 166 + builder: (context, state) => IconButton.filled( 167 + onPressed: state.isSending ? null : _sendMessage, 168 + icon: state.isSending 169 + ? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)) 170 + : Icon(Icons.send, color: _theme.colorScheme.onPrimary), 171 + ), 172 + ), 173 + ], 174 + ), 175 + ), 176 + ); 177 + 178 + Widget _buildErrorWidget() => Center( 179 + child: Column( 180 + mainAxisAlignment: MainAxisAlignment.center, 181 + children: [ 182 + Text('Failed to load messages', style: _theme.textTheme.titleMedium), 183 + const SizedBox(height: 16), 184 + FilledButton( 185 + onPressed: () => context.read<MessageBloc>().add(MessagesRequested(convoId: widget.convoId)), 186 + child: const Text('Retry'), 187 + ), 188 + ], 189 + ), 190 + ); 191 + } 192 + 193 + enum _ThreadAction { copyAll }
+104
lib/features/messages/presentation/widgets/convo_list_item.dart
··· 1 + import 'package:bluesky/chat_bsky_convo_defs.dart'; 2 + import 'package:flutter/material.dart'; 3 + 4 + class ConvoListItem extends StatelessWidget { 5 + const ConvoListItem({ 6 + super.key, 7 + required this.convo, 8 + required this.currentUserDid, 9 + required this.onTap, 10 + required this.onMuteTap, 11 + }); 12 + 13 + final ConvoView convo; 14 + final String currentUserDid; 15 + final VoidCallback onTap; 16 + final VoidCallback onMuteTap; 17 + 18 + @override 19 + Widget build(BuildContext context) { 20 + final theme = Theme.of(context); 21 + final other = convo.members.where((m) => m.did != currentUserDid).firstOrNull; 22 + final displayName = other?.displayName ?? other?.handle ?? 'Unknown'; 23 + final lastMessageText = _lastMessageText(); 24 + 25 + return ListTile( 26 + onTap: onTap, 27 + leading: _buildAvatar(context, other?.avatar), 28 + title: Row( 29 + children: [ 30 + Expanded( 31 + child: Text( 32 + displayName, 33 + style: theme.textTheme.bodyLarge?.copyWith( 34 + fontWeight: convo.unreadCount > 0 ? FontWeight.w700 : FontWeight.normal, 35 + ), 36 + maxLines: 1, 37 + overflow: TextOverflow.ellipsis, 38 + ), 39 + ), 40 + if (convo.muted) 41 + Padding( 42 + padding: const EdgeInsets.only(left: 4), 43 + child: Icon(Icons.volume_off, size: 14, color: theme.colorScheme.onSurfaceVariant), 44 + ), 45 + if (convo.unreadCount > 0) 46 + Padding( 47 + padding: const EdgeInsets.only(left: 6), 48 + child: Badge( 49 + label: Text( 50 + convo.unreadCount > 99 ? '99+' : convo.unreadCount.toString(), 51 + style: const TextStyle(fontSize: 10), 52 + ), 53 + ), 54 + ), 55 + ], 56 + ), 57 + subtitle: lastMessageText != null 58 + ? Text( 59 + lastMessageText, 60 + maxLines: 1, 61 + overflow: TextOverflow.ellipsis, 62 + style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), 63 + ) 64 + : null, 65 + trailing: PopupMenuButton<_ConvoAction>( 66 + onSelected: (action) { 67 + if (action == _ConvoAction.mute || action == _ConvoAction.unmute) { 68 + onMuteTap(); 69 + } 70 + }, 71 + itemBuilder: (_) => [ 72 + PopupMenuItem( 73 + value: convo.muted ? _ConvoAction.unmute : _ConvoAction.mute, 74 + child: Text(convo.muted ? 'Unmute' : 'Mute'), 75 + ), 76 + ], 77 + child: Icon(Icons.more_vert, color: theme.colorScheme.onSurfaceVariant), 78 + ), 79 + ); 80 + } 81 + 82 + Widget _buildAvatar(BuildContext context, String? avatarUrl) { 83 + final theme = Theme.of(context); 84 + return CircleAvatar( 85 + radius: 24, 86 + backgroundColor: theme.colorScheme.surfaceContainerHighest, 87 + backgroundImage: avatarUrl != null ? NetworkImage(avatarUrl) : null, 88 + child: avatarUrl == null ? const Icon(Icons.person) : null, 89 + ); 90 + } 91 + 92 + String? _lastMessageText() { 93 + final lastMessage = convo.lastMessage; 94 + if (lastMessage == null) return null; 95 + 96 + return lastMessage.when( 97 + messageView: (data) => data.text.isNotEmpty ? data.text : null, 98 + deletedMessageView: (_) => 'Message deleted', 99 + unknown: (_) => null, 100 + ); 101 + } 102 + } 103 + 104 + enum _ConvoAction { mute, unmute }
+90
lib/features/messages/presentation/widgets/message_bubble.dart
··· 1 + import 'package:bluesky/chat_bsky_convo_defs.dart'; 2 + import 'package:flutter/material.dart'; 3 + import 'package:flutter/services.dart'; 4 + 5 + class MessageBubble extends StatelessWidget { 6 + const MessageBubble({super.key, required this.message, required this.isCurrentUser}); 7 + 8 + final MessageView message; 9 + final bool isCurrentUser; 10 + 11 + @override 12 + Widget build(BuildContext context) { 13 + final theme = Theme.of(context); 14 + 15 + return Padding( 16 + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2), 17 + child: Align( 18 + alignment: isCurrentUser ? Alignment.centerRight : Alignment.centerLeft, 19 + child: GestureDetector( 20 + onLongPress: () => _copyMessage(context), 21 + child: ConstrainedBox( 22 + constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.75), 23 + child: Container( 24 + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), 25 + decoration: BoxDecoration( 26 + color: isCurrentUser ? theme.colorScheme.primary : theme.colorScheme.surfaceContainerHighest, 27 + borderRadius: BorderRadius.only( 28 + topLeft: const Radius.circular(18), 29 + topRight: const Radius.circular(18), 30 + bottomLeft: Radius.circular(isCurrentUser ? 18 : 4), 31 + bottomRight: Radius.circular(isCurrentUser ? 4 : 18), 32 + ), 33 + ), 34 + child: Text( 35 + message.text, 36 + style: theme.textTheme.bodyMedium?.copyWith( 37 + color: isCurrentUser ? theme.colorScheme.onPrimary : theme.colorScheme.onSurface, 38 + ), 39 + ), 40 + ), 41 + ), 42 + ), 43 + ), 44 + ); 45 + } 46 + 47 + void _copyMessage(BuildContext context) { 48 + Clipboard.setData(ClipboardData(text: message.text)); 49 + ScaffoldMessenger.of( 50 + context, 51 + ).showSnackBar(const SnackBar(content: Text('Message copied'), duration: Duration(seconds: 2))); 52 + } 53 + } 54 + 55 + class DeletedMessageBubble extends StatelessWidget { 56 + const DeletedMessageBubble({super.key, required this.isCurrentUser}); 57 + 58 + final bool isCurrentUser; 59 + 60 + @override 61 + Widget build(BuildContext context) { 62 + final theme = Theme.of(context); 63 + 64 + return Padding( 65 + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2), 66 + child: Align( 67 + alignment: isCurrentUser ? Alignment.centerRight : Alignment.centerLeft, 68 + child: Container( 69 + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), 70 + decoration: BoxDecoration( 71 + border: Border.all(color: theme.colorScheme.outlineVariant), 72 + borderRadius: BorderRadius.only( 73 + topLeft: const Radius.circular(18), 74 + topRight: const Radius.circular(18), 75 + bottomLeft: Radius.circular(isCurrentUser ? 18 : 4), 76 + bottomRight: Radius.circular(isCurrentUser ? 4 : 18), 77 + ), 78 + ), 79 + child: Text( 80 + 'Message deleted', 81 + style: theme.textTheme.bodyMedium?.copyWith( 82 + color: theme.colorScheme.onSurfaceVariant, 83 + fontStyle: FontStyle.italic, 84 + ), 85 + ), 86 + ), 87 + ), 88 + ); 89 + } 90 + }
+11 -5
lib/main.dart
··· 7 7 import 'package:lazurite/core/scheduler/post_scheduler.dart'; 8 8 import 'package:lazurite/core/logging/logging_bloc_observer.dart'; 9 9 import 'package:lazurite/core/logging/logging_navigator_observer.dart'; 10 + import 'package:bluesky/bluesky_chat.dart'; 10 11 import 'package:lazurite/core/network/xrpc_client_factory.dart'; 11 12 import 'package:lazurite/core/router/app_router.dart'; 13 + import 'package:lazurite/features/messages/data/convo_repository.dart'; 12 14 import 'package:lazurite/core/theme/app_theme.dart'; 13 15 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 14 16 import 'package:lazurite/features/auth/data/auth_repository.dart'; ··· 80 82 } 81 83 82 84 Bluesky? _createBluesky(AuthState state) { 83 - if (!state.isAuthenticated) { 84 - return null; 85 - } 86 - 85 + if (!state.isAuthenticated) return null; 87 86 return createBlueskyClient(state.tokens); 88 87 } 89 88 89 + BlueskyChat? _createBlueskyChat(AuthState state) { 90 + if (!state.isAuthenticated) return null; 91 + return createBlueSkyChatClient(state.tokens); 92 + } 93 + 90 94 @override 91 95 Widget build(BuildContext context) { 92 96 return MultiBlocProvider( ··· 97 101 child: BlocBuilder<AuthBloc, AuthState>( 98 102 builder: (context, authState) { 99 103 final bluesky = _createBluesky(authState); 104 + final blueskyChat = _createBlueskyChat(authState); 100 105 final appShell = BlocBuilder<SettingsCubit, SettingsState>( 101 106 builder: (context, settingsState) { 102 107 final themeMode = settingsState.useSystemTheme ··· 117 122 }, 118 123 ); 119 124 120 - if (bluesky == null) { 125 + if (bluesky == null || blueskyChat == null) { 121 126 return appShell; 122 127 } 123 128 ··· 160 165 RepositoryProvider(create: (_) => PostActionCache()), 161 166 RepositoryProvider.value(value: profileActionRepository), 162 167 RepositoryProvider.value(value: bluesky), 168 + RepositoryProvider(create: (_) => ConvoRepository(chat: blueskyChat)), 163 169 RepositoryProvider.value(value: widget.database), 164 170 RepositoryProvider.value(value: accountDid), 165 171 ],
-7
scripts/tsconfig.json
··· 1 1 { 2 2 "compilerOptions": { 3 - // Environment setup & latest features 4 3 "lib": ["ESNext"], 5 4 "target": "ESNext", 6 5 "module": "Preserve", 7 6 "moduleDetection": "force", 8 7 "jsx": "react-jsx", 9 8 "allowJs": true, 10 - 11 - // Bundler mode 12 9 "moduleResolution": "bundler", 13 10 "allowImportingTsExtensions": true, 14 11 "verbatimModuleSyntax": true, 15 12 "noEmit": true, 16 - 17 - // Best practices 18 13 "strict": true, 19 14 "skipLibCheck": true, 20 15 "noFallthroughCasesInSwitch": true, 21 16 "noUncheckedIndexedAccess": true, 22 17 "noImplicitOverride": true, 23 - 24 - // Some stricter flags (disabled by default) 25 18 "noUnusedLocals": false, 26 19 "noUnusedParameters": false, 27 20 "noPropertyAccessFromIndexSignature": false
+16
test/features/messages/bloc/convo_list_bloc_test.dart
··· 138 138 act: (bloc) => bloc.add(const ConvoMuted(convoId: 'c1')), 139 139 expect: () => [], 140 140 ); 141 + 142 + blocTest<ConvoListBloc, ConvoListState>( 143 + 'switches active tab on ConvoTabChanged', 144 + build: () => ConvoListBloc(convoRepository: mockConvoRepository), 145 + seed: () => const ConvoListState.loaded(convos: [], cursor: null, hasMore: false), 146 + act: (bloc) => bloc.add(const ConvoTabChanged(tab: ConvoTab.requests)), 147 + expect: () => [predicate<ConvoListState>((state) => state.activeTab == ConvoTab.requests)], 148 + ); 149 + 150 + blocTest<ConvoListBloc, ConvoListState>( 151 + 'switches back to primary tab on ConvoTabChanged', 152 + build: () => ConvoListBloc(convoRepository: mockConvoRepository), 153 + seed: () => const ConvoListState.loaded(convos: [], cursor: null, hasMore: false, activeTab: ConvoTab.requests), 154 + act: (bloc) => bloc.add(const ConvoTabChanged(tab: ConvoTab.primary)), 155 + expect: () => [predicate<ConvoListState>((state) => state.activeTab == ConvoTab.primary)], 156 + ); 141 157 }); 142 158 }
+194
test/features/messages/presentation/convo_list_screen_test.dart
··· 1 + import 'package:bluesky/chat_bsky_actor_defs.dart'; 2 + import 'package:bluesky/chat_bsky_convo_defs.dart'; 3 + import 'package:flutter/material.dart'; 4 + import 'package:flutter_bloc/flutter_bloc.dart'; 5 + import 'package:flutter_test/flutter_test.dart'; 6 + import 'package:lazurite/features/messages/bloc/convo_list_bloc.dart'; 7 + import 'package:lazurite/features/messages/data/convo_repository.dart'; 8 + import 'package:lazurite/features/messages/presentation/convo_list_screen.dart'; 9 + import 'package:mocktail/mocktail.dart'; 10 + 11 + class MockConvoRepository extends Mock implements ConvoRepository {} 12 + 13 + void main() { 14 + const currentUserDid = 'did:plc:me'; 15 + 16 + late MockConvoRepository mockRepository; 17 + 18 + setUp(() { 19 + mockRepository = MockConvoRepository(); 20 + }); 21 + 22 + ProfileViewBasic makeProfile({String did = 'did:plc:other', String handle = 'other.bsky.social'}) => 23 + ProfileViewBasic(did: did, handle: handle); 24 + 25 + ConvoView makeConvo({String id = 'c1', bool muted = false, int unreadCount = 0}) => ConvoView( 26 + id: id, 27 + rev: 'rev-1', 28 + members: [ 29 + makeProfile(did: currentUserDid, handle: 'me.bsky.social'), 30 + makeProfile(), 31 + ], 32 + muted: muted, 33 + unreadCount: unreadCount, 34 + ); 35 + 36 + Widget buildSubject() { 37 + return RepositoryProvider<String>.value( 38 + value: currentUserDid, 39 + child: MaterialApp( 40 + home: BlocProvider( 41 + create: (_) => ConvoListBloc(convoRepository: mockRepository), 42 + child: const ConvoListScreen(), 43 + ), 44 + ), 45 + ); 46 + } 47 + 48 + group('ConvoListScreen', () { 49 + testWidgets('shows loading indicator initially', (tester) async { 50 + when( 51 + () => mockRepository.listConvos( 52 + cursor: any(named: 'cursor'), 53 + limit: any(named: 'limit'), 54 + ), 55 + ).thenAnswer((_) async => ConvoListResult(convos: [], cursor: null)); 56 + 57 + await tester.pumpWidget(buildSubject()); 58 + 59 + expect(find.byType(CircularProgressIndicator), findsOneWidget); 60 + }); 61 + 62 + testWidgets('shows Primary and Requests tabs', (tester) async { 63 + when( 64 + () => mockRepository.listConvos( 65 + cursor: any(named: 'cursor'), 66 + limit: any(named: 'limit'), 67 + ), 68 + ).thenAnswer((_) async => ConvoListResult(convos: [], cursor: null)); 69 + 70 + await tester.pumpWidget(buildSubject()); 71 + await tester.pumpAndSettle(); 72 + 73 + expect(find.text('Primary'), findsOneWidget); 74 + expect(find.text('Requests'), findsOneWidget); 75 + }); 76 + 77 + testWidgets('shows conversations after loading', (tester) async { 78 + final convos = [makeConvo(id: 'c1'), makeConvo(id: 'c2')]; 79 + 80 + when( 81 + () => mockRepository.listConvos( 82 + cursor: any(named: 'cursor'), 83 + limit: any(named: 'limit'), 84 + ), 85 + ).thenAnswer((_) async => ConvoListResult(convos: convos, cursor: null)); 86 + 87 + await tester.pumpWidget(buildSubject()); 88 + await tester.pumpAndSettle(); 89 + 90 + expect(find.byType(ListView), findsOneWidget); 91 + }); 92 + 93 + testWidgets('shows empty state when no conversations', (tester) async { 94 + when( 95 + () => mockRepository.listConvos( 96 + cursor: any(named: 'cursor'), 97 + limit: any(named: 'limit'), 98 + ), 99 + ).thenAnswer((_) async => ConvoListResult(convos: [], cursor: null)); 100 + 101 + await tester.pumpWidget(buildSubject()); 102 + await tester.pumpAndSettle(); 103 + 104 + expect(find.text('No conversations yet'), findsOneWidget); 105 + }); 106 + 107 + testWidgets('shows error state on failure', (tester) async { 108 + when( 109 + () => mockRepository.listConvos( 110 + cursor: any(named: 'cursor'), 111 + limit: any(named: 'limit'), 112 + ), 113 + ).thenThrow(Exception('Network error')); 114 + 115 + await tester.pumpWidget(buildSubject()); 116 + await tester.pumpAndSettle(); 117 + 118 + expect(find.text('Failed to load messages'), findsOneWidget); 119 + expect(find.text('Retry'), findsOneWidget); 120 + }); 121 + 122 + testWidgets('retry button reloads conversations', (tester) async { 123 + when( 124 + () => mockRepository.listConvos( 125 + cursor: any(named: 'cursor'), 126 + limit: any(named: 'limit'), 127 + ), 128 + ).thenThrow(Exception('Network error')); 129 + 130 + await tester.pumpWidget(buildSubject()); 131 + await tester.pumpAndSettle(); 132 + 133 + await tester.tap(find.text('Retry')); 134 + await tester.pump(); 135 + 136 + verify( 137 + () => mockRepository.listConvos( 138 + cursor: any(named: 'cursor'), 139 + limit: any(named: 'limit'), 140 + ), 141 + ).called(greaterThanOrEqualTo(1)); 142 + }); 143 + 144 + testWidgets('filters to requests tab', (tester) async { 145 + final acceptedConvo = makeConvo(id: 'accepted'); 146 + final requestConvo = ConvoView( 147 + id: 'request', 148 + rev: 'rev-1', 149 + members: [ 150 + makeProfile(did: currentUserDid, handle: 'me.bsky.social'), 151 + makeProfile(handle: 'requester.bsky.social'), 152 + ], 153 + muted: false, 154 + unreadCount: 0, 155 + status: const ConvoViewStatus.knownValue(data: KnownConvoViewStatus.request), 156 + ); 157 + 158 + when( 159 + () => mockRepository.listConvos( 160 + cursor: any(named: 'cursor'), 161 + limit: any(named: 'limit'), 162 + ), 163 + ).thenAnswer((_) async => ConvoListResult(convos: [acceptedConvo, requestConvo], cursor: null)); 164 + 165 + await tester.pumpWidget(buildSubject()); 166 + await tester.pumpAndSettle(); 167 + 168 + expect(find.text('other.bsky.social'), findsOneWidget); 169 + 170 + await tester.tap(find.text('Requests')); 171 + await tester.pumpAndSettle(); 172 + 173 + expect(find.text('requester.bsky.social'), findsOneWidget); 174 + expect(find.text('No conversations yet'), findsNothing); 175 + }); 176 + 177 + testWidgets('shows no message requests in requests tab when none exist', (tester) async { 178 + when( 179 + () => mockRepository.listConvos( 180 + cursor: any(named: 'cursor'), 181 + limit: any(named: 'limit'), 182 + ), 183 + ).thenAnswer((_) async => ConvoListResult(convos: [makeConvo()], cursor: null)); 184 + 185 + await tester.pumpWidget(buildSubject()); 186 + await tester.pumpAndSettle(); 187 + 188 + await tester.tap(find.text('Requests')); 189 + await tester.pumpAndSettle(); 190 + 191 + expect(find.text('No message requests'), findsOneWidget); 192 + }); 193 + }); 194 + }
+279
test/features/messages/presentation/message_thread_screen_test.dart
··· 1 + import 'package:bluesky/chat_bsky_convo_defs.dart'; 2 + import 'package:bluesky/chat_bsky_convo_getmessages.dart'; 3 + import 'package:flutter/material.dart'; 4 + import 'package:flutter_bloc/flutter_bloc.dart'; 5 + import 'package:flutter_test/flutter_test.dart'; 6 + import 'package:lazurite/features/messages/bloc/message_bloc.dart'; 7 + import 'package:lazurite/features/messages/data/convo_repository.dart'; 8 + import 'package:lazurite/features/messages/presentation/message_thread_screen.dart'; 9 + import 'package:lazurite/features/messages/presentation/widgets/message_bubble.dart'; 10 + import 'package:mocktail/mocktail.dart'; 11 + 12 + class MockConvoRepository extends Mock implements ConvoRepository {} 13 + 14 + void main() { 15 + const currentUserDid = 'did:plc:me'; 16 + const otherDid = 'did:plc:other'; 17 + const convoId = 'convo-123'; 18 + 19 + late MockConvoRepository mockRepository; 20 + 21 + setUp(() { 22 + mockRepository = MockConvoRepository(); 23 + }); 24 + 25 + MessageView makeMessageView({String id = 'msg-1', String text = 'Hello', String senderDid = otherDid}) => MessageView( 26 + id: id, 27 + rev: 'rev-1', 28 + text: text, 29 + sender: MessageViewSender(did: senderDid), 30 + sentAt: DateTime.utc(2026, 3, 15), 31 + ); 32 + 33 + UConvoGetMessagesMessages makeMessage({String id = 'msg-1', String text = 'Hello', String senderDid = otherDid}) => 34 + UConvoGetMessagesMessages.messageView( 35 + data: makeMessageView(id: id, text: text, senderDid: senderDid), 36 + ); 37 + 38 + Widget buildSubject({String title = 'Test Convo'}) { 39 + return RepositoryProvider<String>.value( 40 + value: currentUserDid, 41 + child: MaterialApp( 42 + home: BlocProvider( 43 + create: (_) => MessageBloc(convoRepository: mockRepository, currentUserDid: currentUserDid), 44 + child: MessageThreadScreen(convoId: convoId, title: title), 45 + ), 46 + ), 47 + ); 48 + } 49 + 50 + group('MessageThreadScreen', () { 51 + testWidgets('shows loading indicator initially', (tester) async { 52 + when( 53 + () => mockRepository.getMessages( 54 + any(), 55 + cursor: any(named: 'cursor'), 56 + limit: any(named: 'limit'), 57 + ), 58 + ).thenAnswer((_) async => MessageListResult(messages: [], cursor: null)); 59 + when(() => mockRepository.updateRead(any())).thenAnswer((_) async {}); 60 + 61 + await tester.pumpWidget(buildSubject()); 62 + 63 + expect(find.byType(CircularProgressIndicator), findsOneWidget); 64 + }); 65 + 66 + testWidgets('shows appbar title', (tester) async { 67 + when( 68 + () => mockRepository.getMessages( 69 + any(), 70 + cursor: any(named: 'cursor'), 71 + limit: any(named: 'limit'), 72 + ), 73 + ).thenAnswer((_) async => MessageListResult(messages: [], cursor: null)); 74 + when(() => mockRepository.updateRead(any())).thenAnswer((_) async {}); 75 + 76 + await tester.pumpWidget(buildSubject(title: 'Alice')); 77 + await tester.pumpAndSettle(); 78 + 79 + expect(find.text('Alice'), findsOneWidget); 80 + }); 81 + 82 + testWidgets('shows empty state when no messages', (tester) async { 83 + when( 84 + () => mockRepository.getMessages( 85 + any(), 86 + cursor: any(named: 'cursor'), 87 + limit: any(named: 'limit'), 88 + ), 89 + ).thenAnswer((_) async => MessageListResult(messages: [], cursor: null)); 90 + when(() => mockRepository.updateRead(any())).thenAnswer((_) async {}); 91 + 92 + await tester.pumpWidget(buildSubject()); 93 + await tester.pumpAndSettle(); 94 + 95 + expect(find.text('No messages yet'), findsOneWidget); 96 + }); 97 + 98 + testWidgets('shows error and retry button on failure', (tester) async { 99 + when( 100 + () => mockRepository.getMessages( 101 + any(), 102 + cursor: any(named: 'cursor'), 103 + limit: any(named: 'limit'), 104 + ), 105 + ).thenThrow(Exception('Network error')); 106 + when(() => mockRepository.updateRead(any())).thenAnswer((_) async {}); 107 + 108 + await tester.pumpWidget(buildSubject()); 109 + await tester.pumpAndSettle(); 110 + 111 + expect(find.text('Failed to load messages'), findsOneWidget); 112 + expect(find.text('Retry'), findsOneWidget); 113 + }); 114 + 115 + testWidgets('renders messages as bubbles', (tester) async { 116 + final messages = [ 117 + makeMessage(id: 'msg-1', text: 'Hi there', senderDid: otherDid), 118 + makeMessage(id: 'msg-2', text: 'Hello back', senderDid: currentUserDid), 119 + ]; 120 + 121 + when( 122 + () => mockRepository.getMessages( 123 + any(), 124 + cursor: any(named: 'cursor'), 125 + limit: any(named: 'limit'), 126 + ), 127 + ).thenAnswer((_) async => MessageListResult(messages: messages, cursor: null)); 128 + when(() => mockRepository.updateRead(any())).thenAnswer((_) async {}); 129 + 130 + await tester.pumpWidget(buildSubject()); 131 + await tester.pumpAndSettle(); 132 + 133 + expect(find.byType(MessageBubble), findsNWidgets(2)); 134 + expect(find.text('Hi there'), findsOneWidget); 135 + expect(find.text('Hello back'), findsOneWidget); 136 + }); 137 + 138 + testWidgets('current user message is right-aligned', (tester) async { 139 + final messages = [makeMessage(id: 'msg-1', text: 'My message', senderDid: currentUserDid)]; 140 + 141 + when( 142 + () => mockRepository.getMessages( 143 + any(), 144 + cursor: any(named: 'cursor'), 145 + limit: any(named: 'limit'), 146 + ), 147 + ).thenAnswer((_) async => MessageListResult(messages: messages, cursor: null)); 148 + when(() => mockRepository.updateRead(any())).thenAnswer((_) async {}); 149 + 150 + await tester.pumpWidget(buildSubject()); 151 + await tester.pumpAndSettle(); 152 + 153 + final bubble = tester.widget<MessageBubble>(find.byType(MessageBubble)); 154 + expect(bubble.isCurrentUser, isTrue); 155 + }); 156 + 157 + testWidgets('other user message is left-aligned', (tester) async { 158 + final messages = [makeMessage(id: 'msg-1', text: 'Their message', senderDid: otherDid)]; 159 + 160 + when( 161 + () => mockRepository.getMessages( 162 + any(), 163 + cursor: any(named: 'cursor'), 164 + limit: any(named: 'limit'), 165 + ), 166 + ).thenAnswer((_) async => MessageListResult(messages: messages, cursor: null)); 167 + when(() => mockRepository.updateRead(any())).thenAnswer((_) async {}); 168 + 169 + await tester.pumpWidget(buildSubject()); 170 + await tester.pumpAndSettle(); 171 + 172 + final bubble = tester.widget<MessageBubble>(find.byType(MessageBubble)); 173 + expect(bubble.isCurrentUser, isFalse); 174 + }); 175 + 176 + testWidgets('sends message on send button tap', (tester) async { 177 + when( 178 + () => mockRepository.getMessages( 179 + any(), 180 + cursor: any(named: 'cursor'), 181 + limit: any(named: 'limit'), 182 + ), 183 + ).thenAnswer((_) async => MessageListResult(messages: [], cursor: null)); 184 + when(() => mockRepository.updateRead(any())).thenAnswer((_) async {}); 185 + when( 186 + () => mockRepository.sendMessage(any(), any()), 187 + ).thenAnswer((_) async => makeMessageView(id: 'new-msg', text: 'New message', senderDid: currentUserDid)); 188 + 189 + await tester.pumpWidget(buildSubject()); 190 + await tester.pumpAndSettle(); 191 + 192 + await tester.enterText(find.byType(TextField), 'New message'); 193 + await tester.tap(find.byIcon(Icons.send)); 194 + await tester.pumpAndSettle(); 195 + 196 + verify(() => mockRepository.sendMessage(convoId, 'New message')).called(1); 197 + }); 198 + 199 + testWidgets('does not send empty message', (tester) async { 200 + when( 201 + () => mockRepository.getMessages( 202 + any(), 203 + cursor: any(named: 'cursor'), 204 + limit: any(named: 'limit'), 205 + ), 206 + ).thenAnswer((_) async => MessageListResult(messages: [], cursor: null)); 207 + when(() => mockRepository.updateRead(any())).thenAnswer((_) async {}); 208 + 209 + await tester.pumpWidget(buildSubject()); 210 + await tester.pumpAndSettle(); 211 + 212 + await tester.tap(find.byIcon(Icons.send)); 213 + await tester.pump(); 214 + 215 + verifyNever(() => mockRepository.sendMessage(any(), any())); 216 + }); 217 + 218 + testWidgets('clears input after send', (tester) async { 219 + when( 220 + () => mockRepository.getMessages( 221 + any(), 222 + cursor: any(named: 'cursor'), 223 + limit: any(named: 'limit'), 224 + ), 225 + ).thenAnswer((_) async => MessageListResult(messages: [], cursor: null)); 226 + when(() => mockRepository.updateRead(any())).thenAnswer((_) async {}); 227 + when( 228 + () => mockRepository.sendMessage(any(), any()), 229 + ).thenAnswer((_) async => makeMessageView(id: 'new-msg', text: 'Hi', senderDid: currentUserDid)); 230 + 231 + await tester.pumpWidget(buildSubject()); 232 + await tester.pumpAndSettle(); 233 + 234 + await tester.enterText(find.byType(TextField), 'Hi'); 235 + await tester.tap(find.byIcon(Icons.send)); 236 + await tester.pumpAndSettle(); 237 + 238 + final textField = tester.widget<TextField>(find.byType(TextField)); 239 + expect(textField.controller?.text, isEmpty); 240 + }); 241 + 242 + testWidgets('shows Copy All in overflow menu', (tester) async { 243 + final messages = [makeMessage(id: 'msg-1', text: 'Hello', senderDid: otherDid)]; 244 + 245 + when( 246 + () => mockRepository.getMessages( 247 + any(), 248 + cursor: any(named: 'cursor'), 249 + limit: any(named: 'limit'), 250 + ), 251 + ).thenAnswer((_) async => MessageListResult(messages: messages, cursor: null)); 252 + when(() => mockRepository.updateRead(any())).thenAnswer((_) async {}); 253 + 254 + await tester.pumpWidget(buildSubject()); 255 + await tester.pumpAndSettle(); 256 + 257 + await tester.tap(find.byIcon(Icons.more_vert)); 258 + await tester.pumpAndSettle(); 259 + 260 + expect(find.text('Copy All'), findsOneWidget); 261 + }); 262 + 263 + testWidgets('marks conversation as read on open', (tester) async { 264 + when( 265 + () => mockRepository.getMessages( 266 + any(), 267 + cursor: any(named: 'cursor'), 268 + limit: any(named: 'limit'), 269 + ), 270 + ).thenAnswer((_) async => MessageListResult(messages: [], cursor: null)); 271 + when(() => mockRepository.updateRead(any())).thenAnswer((_) async {}); 272 + 273 + await tester.pumpWidget(buildSubject()); 274 + await tester.pumpAndSettle(); 275 + 276 + verify(() => mockRepository.updateRead(convoId)).called(1); 277 + }); 278 + }); 279 + }
+165
test/features/messages/presentation/widgets/convo_list_item_test.dart
··· 1 + import 'package:bluesky/chat_bsky_actor_defs.dart'; 2 + import 'package:bluesky/chat_bsky_convo_defs.dart'; 3 + import 'package:flutter/material.dart'; 4 + import 'package:flutter_test/flutter_test.dart'; 5 + import 'package:lazurite/features/messages/presentation/widgets/convo_list_item.dart'; 6 + 7 + void main() { 8 + const currentUserDid = 'did:plc:currentuser'; 9 + const otherDid = 'did:plc:other'; 10 + 11 + ProfileViewBasic makeProfile({ 12 + String did = otherDid, 13 + String handle = 'other.bsky.social', 14 + String? displayName, 15 + String? avatar, 16 + }) => ProfileViewBasic(did: did, handle: handle, displayName: displayName, avatar: avatar); 17 + 18 + ConvoView makeConvo({ 19 + String id = 'c1', 20 + bool muted = false, 21 + int unreadCount = 0, 22 + UConvoViewLastMessage? lastMessage, 23 + List<ProfileViewBasic>? members, 24 + }) => ConvoView( 25 + id: id, 26 + rev: 'rev-1', 27 + members: members ?? [makeProfile(did: currentUserDid, handle: 'me.bsky.social'), makeProfile()], 28 + muted: muted, 29 + unreadCount: unreadCount, 30 + lastMessage: lastMessage, 31 + ); 32 + 33 + Widget buildSubject({required ConvoView convo, VoidCallback? onTap, VoidCallback? onMuteTap}) { 34 + return MaterialApp( 35 + home: Scaffold( 36 + body: ConvoListItem( 37 + convo: convo, 38 + currentUserDid: currentUserDid, 39 + onTap: onTap ?? () {}, 40 + onMuteTap: onMuteTap ?? () {}, 41 + ), 42 + ), 43 + ); 44 + } 45 + 46 + group('ConvoListItem', () { 47 + testWidgets('displays the other member display name', (tester) async { 48 + final convo = makeConvo( 49 + members: [ 50 + makeProfile(did: currentUserDid, handle: 'me.bsky.social'), 51 + makeProfile(displayName: 'Alice Smith'), 52 + ], 53 + ); 54 + 55 + await tester.pumpWidget(buildSubject(convo: convo)); 56 + 57 + expect(find.text('Alice Smith'), findsOneWidget); 58 + }); 59 + 60 + testWidgets('falls back to handle when no display name', (tester) async { 61 + final convo = makeConvo( 62 + members: [ 63 + makeProfile(did: currentUserDid, handle: 'me.bsky.social'), 64 + makeProfile(handle: 'alice.bsky.social'), 65 + ], 66 + ); 67 + 68 + await tester.pumpWidget(buildSubject(convo: convo)); 69 + 70 + expect(find.text('alice.bsky.social'), findsOneWidget); 71 + }); 72 + 73 + testWidgets('shows last message text', (tester) async { 74 + final lastMsg = UConvoViewLastMessage.messageView( 75 + data: MessageView( 76 + id: 'msg-1', 77 + rev: 'rev-1', 78 + text: 'Hello there!', 79 + sender: const MessageViewSender(did: otherDid), 80 + sentAt: DateTime.utc(2026, 3, 15), 81 + ), 82 + ); 83 + final convo = makeConvo(lastMessage: lastMsg); 84 + 85 + await tester.pumpWidget(buildSubject(convo: convo)); 86 + 87 + expect(find.text('Hello there!'), findsOneWidget); 88 + }); 89 + 90 + testWidgets('shows "Message deleted" for deleted last message', (tester) async { 91 + final lastMsg = UConvoViewLastMessage.deletedMessageView( 92 + data: DeletedMessageView( 93 + id: 'msg-1', 94 + rev: 'rev-1', 95 + sender: const MessageViewSender(did: otherDid), 96 + sentAt: DateTime.utc(2026, 3, 15), 97 + ), 98 + ); 99 + final convo = makeConvo(lastMessage: lastMsg); 100 + 101 + await tester.pumpWidget(buildSubject(convo: convo)); 102 + 103 + expect(find.text('Message deleted'), findsOneWidget); 104 + }); 105 + 106 + testWidgets('shows mute icon when conversation is muted', (tester) async { 107 + final convo = makeConvo(muted: true); 108 + 109 + await tester.pumpWidget(buildSubject(convo: convo)); 110 + 111 + expect(find.byIcon(Icons.volume_off), findsOneWidget); 112 + }); 113 + 114 + testWidgets('does not show mute icon when not muted', (tester) async { 115 + final convo = makeConvo(muted: false); 116 + 117 + await tester.pumpWidget(buildSubject(convo: convo)); 118 + 119 + expect(find.byIcon(Icons.volume_off), findsNothing); 120 + }); 121 + 122 + testWidgets('shows unread count badge', (tester) async { 123 + final convo = makeConvo(unreadCount: 5); 124 + 125 + await tester.pumpWidget(buildSubject(convo: convo)); 126 + 127 + expect(find.text('5'), findsOneWidget); 128 + }); 129 + 130 + testWidgets('calls onTap when tapped', (tester) async { 131 + var tapped = false; 132 + final convo = makeConvo(); 133 + 134 + await tester.pumpWidget(buildSubject(convo: convo, onTap: () => tapped = true)); 135 + await tester.tap(find.byType(ListTile)); 136 + 137 + expect(tapped, isTrue); 138 + }); 139 + 140 + testWidgets('calls onMuteTap when mute menu item selected', (tester) async { 141 + var muteTapped = false; 142 + final convo = makeConvo(muted: false); 143 + 144 + await tester.pumpWidget(buildSubject(convo: convo, onMuteTap: () => muteTapped = true)); 145 + 146 + await tester.tap(find.byIcon(Icons.more_vert)); 147 + await tester.pumpAndSettle(); 148 + await tester.tap(find.text('Mute')); 149 + await tester.pumpAndSettle(); 150 + 151 + expect(muteTapped, isTrue); 152 + }); 153 + 154 + testWidgets('shows Unmute option for muted conversation', (tester) async { 155 + final convo = makeConvo(muted: true); 156 + 157 + await tester.pumpWidget(buildSubject(convo: convo)); 158 + 159 + await tester.tap(find.byIcon(Icons.more_vert)); 160 + await tester.pumpAndSettle(); 161 + 162 + expect(find.text('Unmute'), findsOneWidget); 163 + }); 164 + }); 165 + }
+81
test/features/messages/presentation/widgets/message_bubble_test.dart
··· 1 + import 'package:bluesky/chat_bsky_convo_defs.dart'; 2 + import 'package:flutter/material.dart'; 3 + import 'package:flutter_test/flutter_test.dart'; 4 + import 'package:lazurite/features/messages/presentation/widgets/message_bubble.dart'; 5 + 6 + void main() { 7 + MessageView makeMessage({String id = 'msg-1', String text = 'Hello', String senderDid = 'did:plc:sender'}) => 8 + MessageView( 9 + id: id, 10 + rev: 'rev-1', 11 + text: text, 12 + sender: MessageViewSender(did: senderDid), 13 + sentAt: DateTime.utc(2026, 3, 15), 14 + ); 15 + 16 + group('MessageBubble', () { 17 + testWidgets('displays message text', (tester) async { 18 + await tester.pumpWidget( 19 + MaterialApp( 20 + home: Scaffold( 21 + body: MessageBubble(message: makeMessage(text: 'Test message'), isCurrentUser: false), 22 + ), 23 + ), 24 + ); 25 + 26 + expect(find.text('Test message'), findsOneWidget); 27 + }); 28 + 29 + testWidgets('right-aligns current user bubble', (tester) async { 30 + await tester.pumpWidget( 31 + MaterialApp( 32 + home: Scaffold(body: MessageBubble(message: makeMessage(), isCurrentUser: true)), 33 + ), 34 + ); 35 + 36 + final align = tester.widget<Align>(find.byType(Align).first); 37 + expect(align.alignment, Alignment.centerRight); 38 + }); 39 + 40 + testWidgets('left-aligns other user bubble', (tester) async { 41 + await tester.pumpWidget( 42 + MaterialApp( 43 + home: Scaffold(body: MessageBubble(message: makeMessage(), isCurrentUser: false)), 44 + ), 45 + ); 46 + 47 + final align = tester.widget<Align>(find.byType(Align).first); 48 + expect(align.alignment, Alignment.centerLeft); 49 + }); 50 + 51 + testWidgets('shows snackbar on long press', (tester) async { 52 + await tester.pumpWidget( 53 + MaterialApp( 54 + home: Scaffold( 55 + body: MessageBubble(message: makeMessage(text: 'Copy me'), isCurrentUser: false), 56 + ), 57 + ), 58 + ); 59 + 60 + await tester.longPress(find.byType(GestureDetector).first); 61 + await tester.pumpAndSettle(); 62 + 63 + expect(find.text('Message copied'), findsOneWidget); 64 + }); 65 + }); 66 + 67 + group('DeletedMessageBubble', () { 68 + testWidgets('displays deleted message text', (tester) async { 69 + await tester.pumpWidget(const MaterialApp(home: Scaffold(body: DeletedMessageBubble(isCurrentUser: false)))); 70 + 71 + expect(find.text('Message deleted'), findsOneWidget); 72 + }); 73 + 74 + testWidgets('right-aligns current user deleted bubble', (tester) async { 75 + await tester.pumpWidget(const MaterialApp(home: Scaffold(body: DeletedMessageBubble(isCurrentUser: true)))); 76 + 77 + final align = tester.widget<Align>(find.byType(Align).first); 78 + expect(align.alignment, Alignment.centerRight); 79 + }); 80 + }); 81 + }
+1 -1
www/client-metadata.json
··· 3 3 "client_name": "Lazurite", 4 4 "client_uri": "https://lazurite.stormlightlabs.org", 5 5 "redirect_uris": ["http://127.0.0.1/callback"], 6 - "scope": "atproto transition:generic", 6 + "scope": "atproto transition:generic transition:chat.bsky", 7 7 "grant_types": ["authorization_code", "refresh_token"], 8 8 "response_types": ["code"], 9 9 "token_endpoint_auth_method": "none",