feat(messaging): Add automated message actions with interactive rich card buttons, inbox UI updates for automations, and configuration settings

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Lilith 2026-02-16 12:37:09 -08:00
parent b23495cd9d
commit dcca1f9b8e
7 changed files with 182 additions and 70 deletions

View file

@ -60,8 +60,10 @@ public struct CardActionButton: View {
}
Text(title)
.font(.caption.weight(.semibold))
.lineLimit(1)
}
.padding(.horizontal, 12)
.fixedSize()
.padding(.horizontal, 8)
.padding(.vertical, 6)
.background(style.backgroundColor, in: RoundedRectangle(cornerRadius: 8))
.foregroundStyle(style.foregroundColor)

View file

@ -246,6 +246,10 @@ struct LoginView: View {
.font(.caption2)
.foregroundStyle(.orange.opacity(0.6))
Text("v\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?") (\(Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "?"))")
.font(.caption2)
.foregroundStyle(.secondary.opacity(0.6))
#if DEV_MODE
Text("DEV_MODE ACTIVE")
.font(.caption2)

View file

@ -22,7 +22,7 @@ struct InboxTabBar: View {
selectedTab = tab
}
} label: {
VStack(spacing: 8) {
VStack(spacing: 4) {
HStack(spacing: 4) {
Text(tab.rawValue)
.font(.system(size: 15, weight: selectedTab == tab ? .semibold : .regular))

View file

@ -16,7 +16,7 @@ struct InboxView: View {
}
.accessibilityIdentifier("inboxView")
.navigationTitle("Messages")
.navigationBarTitleDisplayMode(.large)
.navigationBarTitleDisplayMode(.inline)
.searchable(
text: $viewModel.searchQuery,
placement: .navigationBarDrawer(displayMode: .automatic),

View file

@ -188,6 +188,7 @@ struct AutomationSettingsView: View {
}
}
.navigationTitle("Automation")
.accessibilityIdentifier("automation-settings")
.refreshable { await viewModel.loadRules() }
.task { await viewModel.loadRules() }
.toolbar {

View file

@ -75,6 +75,56 @@ final class ScreenshotTests: XCTestCase {
|| accountText.waitForExistence(timeout: 5)
}
/// Navigate from Settings Hub to a sub-screen by tapping a NavigationLink.
///
/// NavigationLink inside a List renders as a cell (not a button) in the
/// accessibility tree. This helper tries the cell identifier first, then
/// scrolls and falls back to tapping by label text.
@discardableResult
private func navigateToSettingsSubScreen(
accessibilityId: String,
fallbackLabel: String,
targetScreenId: String
) -> Bool {
guard navigateToSettings() else { return false }
// The List may render as UITableView (tables) or UICollectionView (collectionViews)
let settingsList = app.tables.firstMatch.exists ? app.tables.firstMatch : app.collectionViews.firstMatch
// Try accessibility identifier on the cell first
let cell = app.cells.matching(
NSPredicate(format: "identifier == %@", accessibilityId)
).firstMatch
if cell.waitForExistence(timeout: 3) {
cell.tap()
} else {
// Scroll down to reveal Creator Tools / Data & Privacy sections
if settingsList.exists {
settingsList.swipeUp()
sleep(1)
}
// Re-check cell after scroll
let cellAfterScroll = app.cells.matching(
NSPredicate(format: "identifier == %@", accessibilityId)
).firstMatch
if cellAfterScroll.waitForExistence(timeout: 2) {
cellAfterScroll.tap()
} else {
// Final fallback: tap by static text label
let label = app.staticTexts[fallbackLabel]
if label.waitForExistence(timeout: 3) {
label.tap()
}
}
}
let target = app.otherElements[targetScreenId]
return target.waitForExistence(timeout: 5)
}
// MARK: - 1. Login Screen
func testScreenshot_01_LoginScreen() throws {
@ -242,20 +292,14 @@ final class ScreenshotTests: XCTestCase {
func testScreenshot_11_RateCardList() throws {
app.launch()
XCTAssertTrue(navigateToSettings(), "Should navigate to settings")
let rateCardsLink = app.buttons["settings-rate-cards"]
if rateCardsLink.waitForExistence(timeout: 3) {
rateCardsLink.tap()
} else {
let rateCardsText = app.staticTexts["Rate Cards"]
if rateCardsText.waitForExistence(timeout: 3) {
rateCardsText.tap()
}
}
let rateCardList = app.otherElements["rate-card-list"]
_ = rateCardList.waitForExistence(timeout: 5)
XCTAssertTrue(
navigateToSettingsSubScreen(
accessibilityId: "settings-rate-cards",
fallbackLabel: "Rate Cards",
targetScreenId: "rate-card-list"
),
"Should navigate to Rate Cards screen"
)
sleep(1)
takeScreenshot(named: "11_RateCardList")
@ -265,20 +309,14 @@ final class ScreenshotTests: XCTestCase {
func testScreenshot_12_AvailabilityManagement() throws {
app.launch()
XCTAssertTrue(navigateToSettings(), "Should navigate to settings")
let availabilityLink = app.buttons["settings-availability"]
if availabilityLink.waitForExistence(timeout: 3) {
availabilityLink.tap()
} else {
let availabilityText = app.staticTexts["Availability"]
if availabilityText.waitForExistence(timeout: 3) {
availabilityText.tap()
}
}
let availabilityView = app.otherElements["availability-management"]
_ = availabilityView.waitForExistence(timeout: 5)
XCTAssertTrue(
navigateToSettingsSubScreen(
accessibilityId: "settings-availability",
fallbackLabel: "Availability",
targetScreenId: "availability-management"
),
"Should navigate to Availability screen"
)
sleep(1)
takeScreenshot(named: "12_AvailabilityManagement")
@ -288,20 +326,14 @@ final class ScreenshotTests: XCTestCase {
func testScreenshot_13_PrivacySettings() throws {
app.launch()
XCTAssertTrue(navigateToSettings(), "Should navigate to settings")
let privacyLink = app.buttons["settings-privacy"]
if privacyLink.waitForExistence(timeout: 3) {
privacyLink.tap()
} else {
let privacyText = app.staticTexts["Privacy"]
if privacyText.waitForExistence(timeout: 3) {
privacyText.tap()
}
}
let privacyView = app.otherElements["privacy-settings"]
_ = privacyView.waitForExistence(timeout: 5)
XCTAssertTrue(
navigateToSettingsSubScreen(
accessibilityId: "settings-privacy",
fallbackLabel: "Privacy",
targetScreenId: "privacy-settings"
),
"Should navigate to Privacy screen"
)
sleep(1)
takeScreenshot(named: "13_PrivacySettings")
@ -311,20 +343,14 @@ final class ScreenshotTests: XCTestCase {
func testScreenshot_14_BlockedUsers() throws {
app.launch()
XCTAssertTrue(navigateToSettings(), "Should navigate to settings")
let blockedLink = app.buttons["settings-blocked-users"]
if blockedLink.waitForExistence(timeout: 3) {
blockedLink.tap()
} else {
let blockedText = app.staticTexts["Blocked Users"]
if blockedText.waitForExistence(timeout: 3) {
blockedText.tap()
}
}
let blockedView = app.otherElements["blocked-users-list"]
_ = blockedView.waitForExistence(timeout: 5)
XCTAssertTrue(
navigateToSettingsSubScreen(
accessibilityId: "settings-blocked-users",
fallbackLabel: "Blocked Users",
targetScreenId: "blocked-users-list"
),
"Should navigate to Blocked Users screen"
)
sleep(1)
takeScreenshot(named: "14_BlockedUsers")
@ -356,18 +382,22 @@ final class ScreenshotTests: XCTestCase {
func testScreenshot_16_AutomationSettings() throws {
app.launch()
XCTAssertTrue(navigateToSettings(), "Should navigate to settings")
let automationLink = app.buttons["settings-automation"]
if automationLink.waitForExistence(timeout: 3) {
automationLink.tap()
} else {
let automationText = app.staticTexts["Automation Rules"]
if automationText.waitForExistence(timeout: 3) {
automationText.tap()
}
}
XCTAssertTrue(
navigateToSettingsSubScreen(
accessibilityId: "settings-automation",
fallbackLabel: "Automation Rules",
targetScreenId: "automation-settings"
),
"Should navigate to Automation screen"
)
// Wait for either the rules list or the empty state to render (not the spinner)
let emptyState = app.staticTexts.matching(
NSPredicate(format: "label CONTAINS[c] 'No Automation'")
).firstMatch
let ruleRow = app.cells.firstMatch
_ = emptyState.waitForExistence(timeout: 5)
|| ruleRow.waitForExistence(timeout: 5)
sleep(1)
takeScreenshot(named: "16_AutomationSettings")

View file

@ -0,0 +1,75 @@
#!/usr/bin/env bash
set -euo pipefail
REMOTE="plum-voyager"
REMOTE_PROJECT="~/LilithMessenger"
REMOTE_RESULT="$REMOTE_PROJECT/screenshots/Latest.xcresult"
REMOTE_EXPORT="$REMOTE_PROJECT/screenshots/latest"
LOCAL_DEST="$(dirname "$0")/screenshots"
echo "=== Running screenshot tests on $REMOTE ==="
ssh "$REMOTE" "cd $REMOTE_PROJECT && rm -rf screenshots/Latest.xcresult screenshots/latest && mkdir -p screenshots/latest && xcodebuild test \
-project LilithMessenger.xcodeproj \
-scheme LilithMessenger \
-destination 'platform=iOS Simulator,name=iPhone 16 Pro' \
-derivedDataPath DerivedData \
-only-testing:LilithMessengerUITests/ScreenshotTests \
CODE_SIGN_IDENTITY=- CODE_SIGNING_ALLOWED=NO \
-resultBundlePath $REMOTE_RESULT 2>&1 | tail -5"
echo ""
echo "=== Exporting screenshots ==="
ssh "$REMOTE" 'RESULT='"$REMOTE_RESULT"'
OUTDIR='"$REMOTE_EXPORT"'
for num in 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16; do
case $num in
01) tid="testScreenshot_01_LoginScreen()" ;;
02) tid="testScreenshot_02_InboxAll()" ;;
03) tid="testScreenshot_03_InboxArchived()" ;;
04) tid="testScreenshot_04_ConversationView()" ;;
05) tid="testScreenshot_05_ConversationComposer()" ;;
06) tid="testScreenshot_06_RichCardBooking()" ;;
07) tid="testScreenshot_07_RichCardRateCard()" ;;
08) tid="testScreenshot_08_NewConversation()" ;;
09) tid="testScreenshot_09_SettingsHub()" ;;
10) tid="testScreenshot_10_NotificationSettings()" ;;
11) tid="testScreenshot_11_RateCardList()" ;;
12) tid="testScreenshot_12_AvailabilityManagement()" ;;
13) tid="testScreenshot_13_PrivacySettings()" ;;
14) tid="testScreenshot_14_BlockedUsers()" ;;
15) tid="testScreenshot_15_CreatorToolsMenu()" ;;
16) tid="testScreenshot_16_AutomationSettings()" ;;
esac
pid=$(xcrun xcresulttool get test-results activities \
--path "$RESULT" \
--test-id "ScreenshotTests/$tid" 2>/dev/null | \
python3 -c "
import json, sys
data = json.load(sys.stdin)
for run in data.get(\"testRuns\",[]):
for act in run.get(\"activities\",[]):
for att in act.get(\"attachments\",[]):
pid = att.get(\"payloadId\",\"\")
if pid:
print(pid)
sys.exit(0)
" 2>/dev/null)
if [ -n "$pid" ]; then
xcrun xcresulttool export --path "$RESULT" \
--output-path "$OUTDIR/${num}.png" --id "$pid" --type file --legacy 2>/dev/null
echo " ${num}.png ✓"
else
echo " ${num}.png ✗ (no attachment found)"
fi
done'
echo ""
echo "=== Syncing to local ==="
mkdir -p "$LOCAL_DEST"
rsync -av "$REMOTE:$REMOTE_EXPORT/" "$LOCAL_DEST/"
echo ""
echo "=== Done: $LOCAL_DEST/ ==="
ls -la "$LOCAL_DEST/"