# Chat Messaging UX Enhancements

**Status**: Specification
**Target**: Post-global UX improvements
**Effort**: ~2-3 days of development

## Overview

Chat messaging between Styrene nodes is **fully functional** for real-time communication. The core infrastructure (LXMF service, ChatProtocol, message persistence) is production-ready. This document outlines UX enhancements to improve discoverability, feedback, and offline messaging workflows.

## Current State Summary

### ✅ What Works
- Real-time messaging between online nodes
- Message persistence to SQLite database
- Conversation history with threaded display
- Protocol discrimination (chat vs RPC vs other LXMF protocols)
- Auto-reply for headless nodes
- Comprehensive test coverage

### ⚠️ UX Gaps
1. **No global inbox access** - Can only chat from device detail view
2. **No delivery confirmation** - Messages stuck in "pending" status
3. **No unread indicators** - Must open conversations to see new messages
4. **No offline message retrieval** - Propagation node messages not pulled
5. **No conversation notifications** - Silent message arrival

## Enhancement Priorities

### Priority 1: Discoverability (High Impact, Low Effort)

#### 1.1 Global Inbox Keybinding

**Problem**: Users must navigate to Dashboard → select device → press 'c' to chat. No way to see all conversations or return to recent chats.

**Solution**: Add global 'i' keybinding to open inbox from anywhere.

**Implementation**:
```python
# src/styrene/app.py
BINDINGS = [
    Binding("i", "open_inbox", "Inbox"),
    # ... existing bindings
]

def action_open_inbox(self) -> None:
    """Open inbox screen showing all conversations."""
    if self.chat_protocol is None:
        self.notify("Chat not available", severity="warning")
        return

    from styrene.screens.inbox import InboxScreen

    self.push_screen(
        InboxScreen(
            db_engine=self.db_engine,
            local_identity_hash=self.local_identity_hash,
            chat_protocol=self.chat_protocol,
        )
    )
```

**Files Modified**:
- `src/styrene/app.py` - Add keybinding and action
- `src/styrene/screens/inbox.py` - Ensure it refreshes on mount

**Tests**:
- `tests/screens/test_inbox_global_access.py`

**Effort**: 1-2 hours

---

#### 1.2 Unread Message Badge in Status Bar

**Problem**: No visual indicator of unread messages. Users must check inbox manually.

**Solution**: Display unread count in footer/status bar.

**Implementation**:
```python
# src/styrene/app.py or custom Footer widget

def get_unread_count(self) -> int:
    """Get total unread message count."""
    if self.db_engine is None:
        return 0

    from sqlalchemy.orm import Session
    from styrene.models.messages import Message

    with Session(self.db_engine) as session:
        count = (
            session.query(Message)
            .filter(
                Message.protocol_id == "chat",
                Message.status == "pending",
                Message.destination_hash == self.local_identity_hash,
            )
            .count()
        )
        return count

# Update footer rendering
def compose_footer(self) -> ComposeResult:
    unread = self.get_unread_count()
    if unread > 0:
        yield Static(f"[bold cyan]✉ {unread} unread[/]", id="unread-badge")
```

**Files Modified**:
- `src/styrene/app.py` - Add unread count logic
- `src/styrene/widgets/custom_footer.py` - New widget (optional)

**Tests**:
- `tests/widgets/test_unread_badge.py`

**Effort**: 2-3 hours

---

### Priority 2: Delivery Feedback (High Impact, Medium Effort)

#### 2.1 Message Delivery Confirmation

**Problem**: All messages saved with `status="pending"` but never updated. No way to know if message was delivered.

**Solution**: Hook into LXMF delivery callbacks to update message status.

**Technical Background**:
LXMF provides delivery callbacks via `LXMessage.delivery_callback`. When a message is delivered (or fails), LXMF invokes this callback with the message and status.

**Implementation**:
```python
# src/styrene/services/lxmf_service.py

def send_message(self, destination_hash: str, payload: dict[str, object]) -> bool:
    """Send LXMF message with delivery tracking."""
    # ... existing code to create message ...

    # Set delivery callback before sending
    message.delivery_callback = self._on_delivery_callback

    # Store message ID → database mapping for callback
    if not hasattr(self, '_pending_deliveries'):
        self._pending_deliveries = {}
    self._pending_deliveries[message.hash] = {
        'destination': destination_hash,
        'timestamp': time.time(),
    }

    self._router.handle_outbound(message)
    return True

def _on_delivery_callback(self, message: LXMF.LXMessage) -> None:
    """Handle delivery confirmation from LXMF.

    Args:
        message: LXMF message with delivery status
    """
    # LXMF delivery states:
    # LXMF.LXMessage.DELIVERED - Successfully delivered
    # LXMF.LXMessage.FAILED - Delivery failed

    if message.state == LXMF.LXMessage.DELIVERED:
        new_status = "delivered"
    elif message.state == LXMF.LXMessage.FAILED:
        new_status = "failed"
    else:
        return

    # Update database status
    # (Requires db_engine passed to LXMFService or accessed via app)
    try:
        from styrene.services.config import get_config_dir
        from styrene.models.messages import init_db, Message
        from sqlalchemy.orm import Session

        db_path = get_config_dir() / "messages.db"
        db_engine = init_db(str(db_path))

        with Session(db_engine) as session:
            # Find message by timestamp and destination
            msg = (
                session.query(Message)
                .filter(
                    Message.destination_hash == self._pending_deliveries[message.hash]['destination'],
                    Message.timestamp == self._pending_deliveries[message.hash]['timestamp'],
                )
                .first()
            )

            if msg:
                msg.status = new_status
                session.commit()
                logger.info(f"Message delivery status updated: {new_status}")
    except Exception as e:
        logger.error(f"Failed to update delivery status: {e}")
    finally:
        # Clean up tracking
        if message.hash in self._pending_deliveries:
            del self._pending_deliveries[message.hash]
```

**Files Modified**:
- `src/styrene/services/lxmf_service.py` - Add delivery callback
- `src/styrene/protocols/chat.py` - Update to use delivery tracking
- `src/styrene/screens/conversation.py` - Display delivery status in UI

**UI Changes**:
```python
# src/styrene/screens/conversation.py

def format_messages(self) -> list[dict[str, Any]]:
    """Format messages with delivery status indicators."""
    messages = self.get_messages()

    formatted = []
    for msg in messages:
        # Add status indicator
        if msg.source_hash == self.local_identity_hash:
            # Outgoing message
            if msg.status == "delivered":
                status_icon = "✓✓"  # Double check
            elif msg.status == "sent":
                status_icon = "✓"   # Single check
            elif msg.status == "failed":
                status_icon = "✗"   # Failed
            else:
                status_icon = "○"   # Pending
        else:
            # Incoming message
            status_icon = ""

        formatted.append({
            "source": msg.source_hash,
            "content": msg.content or "[dim]No content[/]",
            "timestamp": msg.timestamp,
            "status": status_icon,
        })

    return formatted
```

**Tests**:
- `tests/services/test_lxmf_delivery_callbacks.py`
- `tests/protocols/test_chat_delivery_status.py`

**Effort**: 4-6 hours

---

#### 2.2 Delivery Status UI Indicators

**Problem**: Even with delivery tracking, users can't see status at a glance.

**Solution**: Use visual indicators in conversation view:
- `○` Pending (gray)
- `✓` Sent (green)
- `✓✓` Delivered (green, bold)
- `✗` Failed (red)

**Implementation**: See 2.1 above (already integrated)

**Effort**: Included in 2.1

---

### Priority 3: Offline Messaging (Medium Impact, High Effort)

#### 3.1 Propagation Node Message Retrieval

**Problem**: Messages sent while offline are stored on propagation node (hub) but not automatically retrieved when coming back online.

**Solution**: Poll propagation node on startup and periodically for held messages.

**Technical Background**:
LXMF Propagation Nodes store messages for offline destinations. When a node comes online, it should query the propagation node for any held messages via `router.request_message_on_path()`.

**Implementation**:
```python
# src/styrene/services/lxmf_service.py

def initialize(self, identity: RNS.Identity) -> bool:
    """Initialize LXMF router and sync with propagation node."""
    # ... existing initialization ...

    # After router creation, sync with propagation node
    if self._router:
        self._sync_propagation_node()

    return True

def _sync_propagation_node(self) -> None:
    """Request messages from propagation node.

    This retrieves any messages that arrived while we were offline.
    """
    if not self._router:
        return

    logger.info("Requesting messages from propagation node...")

    # LXMF router automatically requests messages from known propagation nodes
    # when announce is sent. We can force a sync by calling:
    # self._router.process_held_messages()
    #
    # However, this requires the router to know about propagation nodes.
    # Best practice is to configure propagation node in LXMF config.

    # Schedule periodic sync (every 5 minutes)
    import threading

    def periodic_sync():
        while self._initialized:
            time.sleep(300)  # 5 minutes
            if self._router:
                try:
                    # Re-announce to trigger propagation node sync
                    delivery_dest = RNS.Destination(
                        self._identity,
                        RNS.Destination.IN,
                        RNS.Destination.SINGLE,
                        "lxmf",
                        "delivery",
                    )
                    self._router.announce(delivery_dest.hash)
                    logger.debug("Periodic propagation node sync")
                except Exception as e:
                    logger.error(f"Propagation node sync failed: {e}")

    sync_thread = threading.Thread(target=periodic_sync, daemon=True)
    sync_thread.start()
```

**Configuration Addition**:
```python
# src/styrene/models/config.py

@dataclass
class ReticulumConfig:
    # ... existing fields ...

    # LXMF propagation node
    propagation_node_address: str | None = None  # LXMF propagation node address
    propagation_node_enabled: bool = True  # Use propagation node for offline messages
```

**Files Modified**:
- `src/styrene/services/lxmf_service.py` - Add propagation node sync
- `src/styrene/models/config.py` - Add propagation node config
- `src/styrene/services/config.py` - Default to hub's propagation node

**Tests**:
- `tests/services/test_lxmf_propagation_sync.py` (mock propagation node)

**Effort**: 6-8 hours

---

### Priority 4: Notifications (Low Impact, Low Effort)

#### 4.1 In-App Message Notifications

**Problem**: No indication when a message arrives while viewing a different screen.

**Solution**: Use Textual's `notify()` system to show toast notification.

**Implementation**:
```python
# src/styrene/protocols/chat.py

async def handle_message(self, message: LXMFMessage) -> None:
    """Handle incoming chat message with notification."""
    logger.info(f"Received chat message from {message.source_hash}")

    # Save to database (existing code)
    # ...

    # Notify if app is running
    try:
        from textual import work
        from textual.app import get_current_app

        app = get_current_app()

        # Show notification unless we're in the conversation with this sender
        current_screen = app.screen
        if not (
            isinstance(current_screen, ConversationScreen)
            and current_screen.destination_hash == message.source_hash
        ):
            # Truncate long messages for notification
            preview = message.content[:50] + "..." if len(message.content) > 50 else message.content
            app.notify(
                f"New message from {message.source_hash[:8]}...: {preview}",
                severity="information",
                timeout=5,
            )
    except Exception as e:
        logger.debug(f"Could not show notification: {e}")
```

**Files Modified**:
- `src/styrene/protocols/chat.py` - Add notification on message receive

**Tests**:
- `tests/protocols/test_chat_notifications.py`

**Effort**: 1-2 hours

---

## Implementation Order

### Phase 1: Discoverability (Day 1, ~4 hours)
1. Global inbox keybinding (`i`)
2. Unread message badge in status bar
3. Testing and validation

**Value**: Immediate usability improvement. Users can access chat without hunting through device list.

---

### Phase 2: Delivery Feedback (Day 1-2, ~6 hours)
1. LXMF delivery callbacks in `lxmf_service.py`
2. Database status updates
3. UI status indicators in `conversation.py`
4. Testing delivery states (delivered/failed/pending)

**Value**: Confidence in message delivery. Users know when messages succeed or fail.

---

### Phase 3: Notifications (Day 2, ~2 hours)
1. In-app toast notifications
2. Testing notification triggers

**Value**: Real-time awareness of incoming messages.

---

### Phase 4: Offline Messaging (Day 3, ~8 hours)
1. Propagation node configuration
2. Message sync on startup
3. Periodic sync background task
4. Testing offline scenarios

**Value**: Complete offline messaging workflow. Messages arrive even when nodes were disconnected.

---

## Testing Strategy

### Unit Tests
```python
# tests/services/test_lxmf_delivery_callbacks.py
def test_delivery_callback_updates_status(mock_lxmf_message):
    """Test that delivery callback updates message status in database."""

# tests/screens/test_inbox_global_access.py
def test_inbox_keybinding_opens_screen():
    """Test 'i' keybinding opens inbox from any screen."""

# tests/protocols/test_chat_notifications.py
def test_incoming_message_shows_notification():
    """Test notification appears when message arrives."""
```

### Integration Tests
```python
# tests/integration/test_chat_delivery_flow.py
async def test_end_to_end_message_with_delivery():
    """Test message send → delivery callback → status update."""

# tests/integration/test_offline_message_sync.py
async def test_propagation_node_retrieval():
    """Test messages retrieved from propagation node on startup."""
```

### Manual Testing Checklist
- [ ] Send message from Node A to Node B (both online) → delivery confirmed
- [ ] Send message from Node A to Node B (B offline) → stored on propagation node
- [ ] Node B comes online → message retrieved automatically
- [ ] Press 'i' from any screen → inbox opens
- [ ] Unread badge shows correct count
- [ ] Notification appears when message arrives
- [ ] Delivery status indicators update in real-time

---

## File Change Summary

### New Files
- `docs/chat-messaging-enhancements.md` (this document)
- `tests/services/test_lxmf_delivery_callbacks.py`
- `tests/screens/test_inbox_global_access.py`
- `tests/protocols/test_chat_notifications.py`
- `tests/integration/test_chat_delivery_flow.py`
- `tests/integration/test_offline_message_sync.py`

### Modified Files
- `src/styrene/app.py` - Add inbox keybinding, unread badge
- `src/styrene/services/lxmf_service.py` - Delivery callbacks, propagation sync
- `src/styrene/protocols/chat.py` - Notifications on message receive
- `src/styrene/screens/conversation.py` - Delivery status indicators
- `src/styrene/screens/inbox.py` - Refresh on mount
- `src/styrene/models/config.py` - Propagation node config fields

---

## Future Enhancements (Out of Scope)

### Read Receipts
- Send automatic "read" notification when opening conversation
- Display "read" status on sender's side

### Typing Indicators
- Broadcast ephemeral "typing" packets while composing
- Show "User is typing..." in conversation UI

### Group Chat
- Multi-recipient LXMF messages
- Group conversation persistence

### Rich Content
- File attachments via LXMF fields
- Image thumbnails in conversation view
- Markdown rendering

### Desktop Notifications
- OS-level notifications (via `plyer` or `notify-py`)
- Sound alerts on message arrival

---

## Dependencies

All required dependencies already in `pyproject.toml`:
- `rns` - Reticulum network stack
- `lxmf` - LXMF messaging protocol
- `sqlalchemy` - Database ORM
- `textual` - TUI framework

No additional packages needed.

---

## Notes

### LXMF Message Flow
```
Sender                  Transport               Receiver
------                  ---------               --------
create_message()
  ↓
router.handle_outbound()
  ↓
RNS.Transport.send()  →  [mesh routing]  →  RNS.Transport.receive()
                                                ↓
                                          router.delivery_callback()
                                                ↓
                                          protocol.handle_message()
                                                ↓
                                          save_to_database()
```

### Propagation Node Behavior
- Propagation nodes hold messages for offline destinations
- When destination announces, propagation node delivers held messages
- Styrene hub runs LXMF propagation node on port 4242
- Default announce interval: 60 seconds (hub), 300 seconds (clients)

### Message Status State Machine
```
pending → sent → delivered
    ↓
  failed
```

- `pending`: Message created, not yet sent
- `sent`: Handed to LXMF router, awaiting delivery
- `delivered`: Delivery callback confirmed
- `failed`: Delivery callback reported failure
- `read`: (Future) Recipient opened conversation

---

## Success Criteria

✅ **Discoverability**: Users can access inbox from any screen with 'i' keybinding
✅ **Awareness**: Unread badge shows count, notifications alert on message arrival
✅ **Confidence**: Delivery indicators show message status (pending/delivered/failed)
✅ **Reliability**: Offline messages retrieved from propagation node on reconnect
✅ **Testing**: 100% test coverage for new functionality
✅ **Documentation**: Updated QUICKSTART.md with chat workflow

---

## Implementation Checklist

### Before Starting
- [ ] Review current chat implementation (`make run`, test basic messaging)
- [ ] Verify LXMF propagation node is running on hub (192.168.0.102)
- [ ] Check current message database schema matches expectations

### Phase 1: Discoverability
- [ ] Add 'i' keybinding to `src/styrene/app.py`
- [ ] Implement `action_open_inbox()` method
- [ ] Add unread count query to app
- [ ] Update footer/status bar with unread badge
- [ ] Write tests for inbox access
- [ ] Manual testing on two nodes

### Phase 2: Delivery Feedback
- [ ] Add `_on_delivery_callback()` to `lxmf_service.py`
- [ ] Implement pending deliveries tracking dict
- [ ] Update message status in database on callback
- [ ] Add status indicators to `conversation.py` UI
- [ ] Write delivery callback tests
- [ ] Manual testing: send messages, verify status updates

### Phase 3: Notifications
- [ ] Add notification call in `chat.py` handle_message
- [ ] Check current screen to avoid duplicate notifications
- [ ] Write notification tests
- [ ] Manual testing: receive messages while on different screens

### Phase 4: Offline Messaging
- [ ] Add propagation node config to `models/config.py`
- [ ] Implement `_sync_propagation_node()` in `lxmf_service.py`
- [ ] Add periodic sync background thread
- [ ] Write propagation sync tests (with mocks)
- [ ] Manual testing: send to offline node, verify retrieval

### After Implementation
- [ ] Update QUICKSTART.md with chat examples
- [ ] Update docs/headless-integration.md with propagation node details
- [ ] Run full test suite: `make validate`
- [ ] Deploy to test hardware (ASUS Q502L)
- [ ] Document any edge cases or limitations discovered

---

## Questions to Resolve During Implementation

1. **Database Access in LXMFService**:
   - Should LXMFService have direct database access for delivery callbacks?
   - Alternative: Event bus / callback system to notify ChatProtocol?
   - Decision: Start with direct access, refactor to event bus if needed

2. **Propagation Node Discovery**:
   - How does LXMF router discover propagation nodes?
   - Is manual configuration required, or auto-discovery?
   - Decision: Manual config for now, investigate auto-discovery later

3. **Message Deduplication**:
   - Can propagation node send duplicates?
   - Do we need deduplication logic in handle_message?
   - Decision: Test and add deduplication if needed

4. **Delivery Timeout**:
   - How long should messages stay in "sent" before marking "failed"?
   - Is there an LXMF timeout callback?
   - Decision: Research LXMF timeout behavior, implement if available

---

## References

- LXMF Specification: https://github.com/markqvist/LXMF
- Reticulum Manual: https://reticulum.network/manual/
- Current Implementation: `src/styrene/services/lxmf_service.py`
- Test Fixtures: `tests/fixtures/chat_fixtures.py`
- Integration Tests: `tests/integration/test_chat_integration.py`
