diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2868454..3f4747f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -80,6 +80,11 @@ dependencies { ksp(libs.hilt.compiler) implementation(libs.androidx.hilt.navigation.compose) + // WorkManager + Hilt worker factory (bootstraps the documents delta-sync @HiltWorker) + implementation(libs.androidx.work.runtime.ktx) + implementation(libs.androidx.hilt.work) + ksp(libs.androidx.hilt.compiler) + // Test testImplementation(libs.junit) androidTestImplementation(libs.androidx.test.ext.junit) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 759d7c7..b99fdcc 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,8 @@ xmlns:tools="http://schemas.android.com/tools"> + + + + + + + diff --git a/app/src/main/java/com/interlinedlist/android/InterlinedListApplication.kt b/app/src/main/java/com/interlinedlist/android/InterlinedListApplication.kt index d33d7f7..5c1092d 100644 --- a/app/src/main/java/com/interlinedlist/android/InterlinedListApplication.kt +++ b/app/src/main/java/com/interlinedlist/android/InterlinedListApplication.kt @@ -1,8 +1,35 @@ package com.interlinedlist.android import android.app.Application +import androidx.hilt.work.HiltWorkerFactory +import androidx.work.Configuration +import com.interlinedlist.android.feature.notifications.push.SystemNotificationChannels import dagger.hilt.android.HiltAndroidApp +import javax.inject.Inject -/** Application entry point; bootstraps the Hilt dependency graph. */ +/** + * Application entry point; bootstraps the Hilt dependency graph. + * + * Also supplies the WorkManager [Configuration] on demand (paired with removing the + * default `androidx.startup` WorkManager initializer in the manifest) so `@HiltWorker` + * instances — such as the documents delta-sync worker and the notifications poll + * worker — can be constructed by Hilt. + */ @HiltAndroidApp -class InterlinedListApplication : Application() +class InterlinedListApplication : Application(), Configuration.Provider { + + @Inject + lateinit var workerFactory: HiltWorkerFactory + + override val workManagerConfiguration: Configuration + get() = Configuration.Builder() + .setWorkerFactory(workerFactory) + .build() + + override fun onCreate() { + super.onCreate() + // Register the system notification channels up front (idempotent, no-op < O) + // so the background poll can post into named channels the user can tune. + SystemNotificationChannels.ensureRegistered(this) + } +} diff --git a/app/src/main/java/com/interlinedlist/android/MainActivity.kt b/app/src/main/java/com/interlinedlist/android/MainActivity.kt index 633b288..01ae740 100644 --- a/app/src/main/java/com/interlinedlist/android/MainActivity.kt +++ b/app/src/main/java/com/interlinedlist/android/MainActivity.kt @@ -4,10 +4,16 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.getValue import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.interlinedlist.android.core.datastore.SessionStore +import com.interlinedlist.android.core.datastore.ThemeMode +import com.interlinedlist.android.core.datastore.ThemeSettingsStore import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme import com.interlinedlist.android.navigation.InterlinedListNavHost +import com.interlinedlist.android.navigation.NotificationLaunch import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject @@ -18,15 +24,30 @@ class MainActivity : ComponentActivity() { @Inject lateinit var sessionStore: SessionStore + @Inject + lateinit var themeSettingsStore: ThemeSettingsStore + override fun onCreate(savedInstanceState: Bundle?) { installSplashScreen() super.onCreate(savedInstanceState) // Resolve the entry screen before composing so there's no login flash. val startLoggedIn = sessionStore.isLoggedIn + // A tapped system notification launches us with deep-link extras; resolve the + // pending in-app route so the signed-in shell can navigate straight to it. + val notificationRoute = NotificationLaunch.fromIntent(intent)?.route enableEdgeToEdge() setContent { - InterlinedListTheme { - InterlinedListNavHost(startLoggedIn = startLoggedIn) + val themeMode by themeSettingsStore.themeMode.collectAsStateWithLifecycle() + val darkTheme = when (themeMode) { + ThemeMode.LIGHT -> false + ThemeMode.DARK -> true + ThemeMode.SYSTEM -> isSystemInDarkTheme() + } + InterlinedListTheme(darkTheme = darkTheme) { + InterlinedListNavHost( + startLoggedIn = startLoggedIn, + notificationRoute = notificationRoute, + ) } } } diff --git a/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt b/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt index b39c2c7..cf0ec9d 100644 --- a/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt +++ b/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt @@ -1,5 +1,10 @@ package com.interlinedlist.android.navigation +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.List @@ -13,9 +18,13 @@ import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.ContextCompat import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavDestination.Companion.hierarchy import androidx.navigation.NavGraph.Companion.findStartDestination @@ -36,6 +45,7 @@ import com.interlinedlist.android.feature.directmessages.navigation.navigateToNe import com.interlinedlist.android.feature.documents.ui.browser.DocumentsFolderRoute import com.interlinedlist.android.feature.documents.ui.browser.DocumentsRoute import com.interlinedlist.android.feature.documents.ui.editor.DocumentEditorRoute +import com.interlinedlist.android.feature.documents.sync.DocumentsSyncScheduler import com.interlinedlist.android.feature.documents.ui.share.DocumentShareRoute import com.interlinedlist.android.feature.documents.ui.share.SharedDocumentRoute import com.interlinedlist.android.feature.documents.ui.collaborators.DocumentCollaboratorsRoute @@ -56,6 +66,7 @@ import com.interlinedlist.android.feature.lists.ui.watchers.WatchersRoute import com.interlinedlist.android.feature.messages.ui.detail.MessageDetailRoute import com.interlinedlist.android.feature.messages.ui.feed.MessagesRoute import com.interlinedlist.android.feature.messages.ui.scheduled.ScheduledMessagesRoute +import com.interlinedlist.android.feature.notifications.push.NotificationsSyncScheduler import com.interlinedlist.android.feature.notifications.ui.NotificationPreferencesRoute import com.interlinedlist.android.feature.notifications.ui.NotificationsRoute import com.interlinedlist.android.feature.organizations.ui.detail.OrganizationDetailRoute @@ -177,7 +188,10 @@ private enum class HomeTab(val route: String, val label: String, val icon: Image * back stack; sign-out returns to login. */ @Composable -fun InterlinedListNavHost(startLoggedIn: Boolean) { +fun InterlinedListNavHost( + startLoggedIn: Boolean, + notificationRoute: String? = null, +) { val navController = rememberNavController() NavHost( navController = navController, @@ -197,8 +211,14 @@ fun InterlinedListNavHost(startLoggedIn: Boolean) { ) } composable(Routes.MAIN) { + val context = LocalContext.current MainShell( + notificationRoute = notificationRoute, onLoggedOut = { + // Stop background sync/poll for the signed-out session. Cancellation + // must never crash the sign-out flow, so any failure is swallowed. + runCatching { DocumentsSyncScheduler.cancelAll(context) } + runCatching { NotificationsSyncScheduler.cancelAll(context) } navController.navigate(AuthRoutes.GRAPH) { popUpTo(Routes.MAIN) { inclusive = true } } @@ -214,11 +234,45 @@ fun InterlinedListNavHost(startLoggedIn: Boolean) { * their own back navigation. */ @Composable -private fun MainShell(onLoggedOut: () -> Unit) { +private fun MainShell( + notificationRoute: String? = null, + onLoggedOut: () -> Unit, +) { val tabNav = rememberNavController() val backStackEntry by tabNav.currentBackStackEntryAsState() val currentRoute = backStackEntry?.destination?.route val onTabRoot = HomeTab.entries.any { it.route == currentRoute } + val context = LocalContext.current + + // Bootstrap the notification poll for the signed-in session: register the periodic + // near-real-time poll and kick a one-shot so the last-seen marker seeds immediately. + // On Android 13+ request POST_NOTIFICATIONS first (silently ignored below 13, where + // the permission does not exist). Scheduling must never crash the shell, so failures + // are swallowed. Runs once when the shell enters. + val requestNotificationsPermission = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { /* result ignored: the poll still runs; posting is a no-op if denied */ } + LaunchedEffect(Unit) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val granted = ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS, + ) == PackageManager.PERMISSION_GRANTED + if (!granted) requestNotificationsPermission.launch(Manifest.permission.POST_NOTIFICATIONS) + } + runCatching { + NotificationsSyncScheduler.schedulePeriodic(context) + NotificationsSyncScheduler.syncNow(context) + } + } + + // Route straight to a tapped notification's destination once, when present. + val pendingRoute by rememberUpdatedState(notificationRoute) + LaunchedEffect(Unit) { + pendingRoute?.let { route -> + runCatching { tabNav.navigate(route) } + } + } Scaffold( bottomBar = { @@ -345,6 +399,16 @@ private fun MainShell(onLoggedOut: () -> Unit) { // ---- Documents ---- composable(Routes.DOCUMENTS) { + // Bootstrap the documents delta-sync: register the periodic pull/push and + // kick a one-shot sync when the Documents tab is opened. Scheduling must + // never crash the UI, so any failure is swallowed defensively. + val context = LocalContext.current + LaunchedEffect(Unit) { + runCatching { + DocumentsSyncScheduler.schedulePeriodic(context) + DocumentsSyncScheduler.syncNow(context) + } + } DocumentsRoute( onOpenFolder = { id -> tabNav.navigate(Routes.documentFolder(id)) }, onOpenDocument = { id -> tabNav.navigate(Routes.documentEditor(id)) }, diff --git a/app/src/main/java/com/interlinedlist/android/navigation/NotificationLaunch.kt b/app/src/main/java/com/interlinedlist/android/navigation/NotificationLaunch.kt new file mode 100644 index 0000000..4034325 --- /dev/null +++ b/app/src/main/java/com/interlinedlist/android/navigation/NotificationLaunch.kt @@ -0,0 +1,48 @@ +package com.interlinedlist.android.navigation + +import android.content.Intent +import com.interlinedlist.android.feature.notifications.push.NotificationDeepLink + +/** + * A pending in-app destination derived from a tapped system notification. The + * notifications module attaches [NotificationDeepLink] extras to the launch intent; + * this parses them into a concrete nav route (falling back to the notifications feed), + * so the tap lands the signed-in user on the right screen. + */ +data class NotificationLaunch(val route: String) { + + companion object { + + /** + * Parses [intent] into a [NotificationLaunch], or null when it did not + * originate from a notification tap. Unresolvable targets fall back to the + * in-app notifications feed. + */ + fun fromIntent(intent: Intent?): NotificationLaunch? { + if (intent?.getBooleanExtra(NotificationDeepLink.EXTRA_FROM_NOTIFICATION, false) != true) { + return null + } + val destination = runCatching { + NotificationDeepLink.Destination.valueOf( + intent.getStringExtra(NotificationDeepLink.EXTRA_DESTINATION).orEmpty(), + ) + }.getOrDefault(NotificationDeepLink.Destination.NOTIFICATIONS) + + val targetId = intent.getStringExtra(NotificationDeepLink.EXTRA_TARGET_ID) + return NotificationLaunch(routeFor(destination, targetId)) + } + + private fun routeFor( + destination: NotificationDeepLink.Destination, + targetId: String?, + ): String = when (destination) { + NotificationDeepLink.Destination.MESSAGE -> + targetId?.let { Routes.messageDetail(it) } ?: Routes.NOTIFICATIONS + NotificationDeepLink.Destination.USER -> + targetId?.let { Routes.userProfile(it) } ?: Routes.NOTIFICATIONS + NotificationDeepLink.Destination.LIST -> + targetId?.let { Routes.listDetail(it) } ?: Routes.NOTIFICATIONS + NotificationDeepLink.Destination.NOTIFICATIONS -> Routes.NOTIFICATIONS + } + } +} diff --git a/app/src/main/res/drawable-night/il_splash_icon.xml b/app/src/main/res/drawable-night/il_splash_icon.xml new file mode 100644 index 0000000..dd2b3c1 --- /dev/null +++ b/app/src/main/res/drawable-night/il_splash_icon.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/app/src/main/res/drawable-nodpi/il_logo_mark.png b/app/src/main/res/drawable-nodpi/il_logo_mark.png deleted file mode 100644 index 01ac3a5..0000000 Binary files a/app/src/main/res/drawable-nodpi/il_logo_mark.png and /dev/null differ diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml index 90219af..67f4a0c 100644 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -1,5 +1,13 @@ - - - + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_monochrome.xml b/app/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 0000000..2d9710b --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/app/src/main/res/drawable/il_splash_icon.xml b/app/src/main/res/drawable/il_splash_icon.xml new file mode 100644 index 0000000..67f4a0c --- /dev/null +++ b/app/src/main/res/drawable/il_splash_icon.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index d70df48..40eab51 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -1,6 +1,6 @@ - + - + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml index d70df48..40eab51 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -1,6 +1,6 @@ - + - + diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml new file mode 100644 index 0000000..2f42062 --- /dev/null +++ b/app/src/main/res/values-night/colors.xml @@ -0,0 +1,5 @@ + + + + #121317 + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 76c7982..21411a9 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,5 +1,7 @@ - - #0F4C5F + + #F4EEE2 + + #F4EEE2 diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index bd04fac..45a2b2f 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,15 +1,16 @@ - + diff --git a/brand-kit/HANDOFF.md b/brand-kit/HANDOFF.md new file mode 100644 index 0000000..4219d0d --- /dev/null +++ b/brand-kit/HANDOFF.md @@ -0,0 +1,225 @@ +# InterlinedList — Handoff & Integration Guide + +How to route this kit to each team and get them productive fast in +**Claude-enabled projects** (Claude Code, or a Claude project with these files +attached as knowledge). The pattern is the same everywhere: + +> **The 3-step pattern** +> 1. Create the team's project and drop in the **subset of `brand-kit/` listed below**. +> 2. Add a short **`CLAUDE.md`** (project instructions) that points Claude at +> `theme/tokens.json` as the source of truth and `guidelines/brand-guidelines.html` +> as the visual reference — and says "use these tokens, do not invent colors, type, or spacing." +> 3. Paste the **starter prompt** given for that team. + +`theme/tokens.json` is the canonical, machine-readable source. Every platform +file (`interlinedlist-theme.css`, `android-colors.xml`, `android-Color.kt`, +`ios-ILColor.swift`) is derived from it — if a value ever conflicts, tokens.json wins. + +Brand one-liner to include in every project's CLAUDE.md: + +> InterlinedList uses the **Strata** system: teal `#184860` structure, green +> `#2FA877` actions, amber `#F0A830` for live/Dig, soft-sand light `#F4EEE2` and +> near-black dark `#121317`. Type: Space Grotesk (display) / Manrope (body) / +> JetBrains Mono (time & meta). Sharp corners (3–4px), 4pt spacing grid. +> Dark mode follows the OS. Never introduce colors, fonts, or radii outside the tokens. + +--- + +## 1 · React Web Site Team + +**Provide:** +- `theme/interlinedlist-theme.css` (variables + `prefers-color-scheme` + `.il-*` components) +- `theme/tokens.json` +- `logo/logo-icon.svg` (use inline/as ``; scales crisply) +- `icons/web/` (favicons 16/32/48, `apple-touch-icon-180`, `icon-192`, `icon-512`, `maskable-512`, dark favicon) +- `guidelines/brand-guidelines.html` + +**CLAUDE.md pointers:** "Import `interlinedlist-theme.css` once at the root. Style +everything with the `--il-*` custom properties; dark mode is automatic via +`prefers-color-scheme`, and `` forces it (wire this +to a Settings toggle). Load the 3 Google Fonts. Never hard-code hex values." + +**Starter prompt:** "Re-theme the site to Strata using `interlinedlist-theme.css`. +Start with the feed: masthead (teal), left nav, color-edged post cards +(teal/green/amber left border), right rail with the stream-clock widget and +trending tags. Add a Light/Dark/System switch in Settings. Wire the favicons and +web manifest from `icons/web/`." + +**Deliverables:** themed feed, thread, profile, compose, settings, auth; +`site.webmanifest` referencing the web icons; a working theme toggle. + +--- + +## 2 · iOS App Team + +**Provide:** +- `theme/ios-ILColor.swift` +- `theme/tokens.json` +- `icons/ios/` (`AppIcon-*.png` 1024→40 + `AppIcon-dark-1024.png`) +- `logo/logo-icon.svg` (in-app mark) + `guidelines/brand-guidelines.html` + +**CLAUDE.md pointers:** "Use `ILColor` for all colors — they adapt to light/dark +via `UITraitCollection` automatically. Register Space Grotesk, Manrope, JetBrains +Mono and reference them via `ILType`. Use `ILMetric` for radius/spacing. Add the +iOS PNGs to an `AppIcon` image set in the Asset Catalog (include the dark 1024 as +the dark appearance)." + +**Starter prompt:** "Build the SwiftUI feed with Strata: teal nav bar, color-edged +post rows, amber Dig button, bottom tab bar with a green compose FAB. Timestamps in +JetBrains Mono (military Zulu). Support Dynamic Type and dark mode." + +**Deliverables:** themed feed/thread/profile/compose, `AppIcon` set installed, +fonts bundled and registered in `Info.plist`. + +--- + +## 3 · macOS App Team + +**Provide:** +- `theme/ios-ILColor.swift` (shared SwiftUI tokens — works on macOS) +- `theme/tokens.json` +- `icons/macos/InterlinedList.icns` (ready to drop in) + the `icon-*.png` sources +- `logo/logo-icon.svg` + `guidelines/brand-guidelines.html` + +**CLAUDE.md pointers:** "Set `InterlinedList.icns` as the app icon in the target's +General tab. Use `ILColor` tokens; the desktop window uses the 10px radius +(`ILMetric.radiusLg`) with traffic-light chrome. Provide a teal icon sidebar." + +**Starter prompt:** "Build the macOS window: traffic-light title bar showing the +subtle stream clock, a teal icon sidebar, and the color-edged feed. Match the +macOS surface in the design doc." + +**Deliverables:** themed window + sidebar, `.icns` installed, light/dark support. + +--- + +## 4 · Windows App Team + +**Provide:** +- `icons/windows/InterlinedList.ico` (multi-size 16–256) + `icon-*.png` sources +- `theme/tokens.json` +- `logo/logo-icon.svg` + `guidelines/brand-guidelines.html` +- (If WinUI/XAML) generate a `Colors.xaml` from tokens.json — ask Claude to. + +**CLAUDE.md pointers:** "App icon is `InterlinedList.ico`. Build the color +resource dictionary from `tokens.json` (light + dark). Title bar uses the deep-teal +`#0C2C3A`; right-aligned min/max/close. Sharp 4px card corners." + +**Starter prompt:** "Theme the Windows (WinUI/Electron) app to Strata from +`tokens.json`: teal title bar, color-edged feed cards, green primary buttons, amber +live/Dig. Wire `InterlinedList.ico` as the executable and window icon. Respect the +OS light/dark setting." + +**Deliverables:** themed shell, `.ico` wired, generated color dictionary, +dark-mode following the system. + +--- + +## 5 · Linux App Team + +**Provide:** +- `icons/linux/` (`icon-*.png` 512→48, hicolor sizes) + `logo/logo-icon.svg` +- `theme/tokens.json` +- `theme/interlinedlist-theme.css` (if the app is web-tech / GTK-WebKit / Electron) +- `guidelines/brand-guidelines.html` + +**CLAUDE.md pointers:** "Install PNGs into `hicolor//apps/interlinedlist.png` +and the SVG into `hicolor/scalable/apps/interlinedlist.svg`; ship a `.desktop` +entry. For GTK/Qt, generate a theme/QSS or GTK CSS from `tokens.json`. Window +controls are left-aligned on GNOME; body is identical to other desktops." + +**Starter prompt:** "Package the Linux app with Strata branding: install the +hicolor icon set + scalable SVG, write the `.desktop` file, and generate the +GTK/Qt stylesheet from `tokens.json` with light/dark variants." + +**Deliverables:** hicolor icon install + `.desktop`, generated stylesheet, +light/dark parity. + +--- + +## 6 · Android App Team + +**Provide:** +- `theme/android-colors.xml` (View system) **and** `theme/android-Color.kt` (Compose) +- `theme/tokens.json` +- `icons/android/` (`ic_launcher-*.png` + `ic_launcher_foreground-*` + `ic_launcher_background-512`) +- `logo/logo-icon.svg` + `guidelines/brand-guidelines.html` + +**CLAUDE.md pointers:** "Split `android-colors.xml` into `values/colors.xml` + +`values-night/` overrides, or use `android-Color.kt` with a Compose +`lightColorScheme`/`darkColorScheme`. Build the adaptive launcher icon from +`ic_launcher_foreground` + `ic_launcher_background` (mipmap-anydpi-v26). Register +the three fonts in `res/font`." + +**Starter prompt:** "Theme the Android app to Strata with Material 3: map +`android-Color.kt` into light/dark color schemes, build the adaptive launcher icon +from the provided fg/bg layers, and lay out the feed with color-edged cards, a +green compose FAB, and JetBrains Mono timestamps." + +**Deliverables:** M3 theme (light + night), adaptive launcher icon, fonts, +themed feed/compose. + +--- + +## 7 · Documentation Team + +**Provide:** +- The **entire `brand-kit/`** (they document all of it) +- `guidelines/brand-guidelines.html` (canonical visual reference) +- `theme/tokens.json` + `README.md` + this `HANDOFF.md` +- The interactive design doc (`InterlinedList Redesign` — screenshots or the live file) + +**CLAUDE.md pointers:** "Treat `tokens.json` as the single source of truth. When +documenting a token, show its light and dark value and its role. Keep every code +sample in sync with the platform files in `theme/`." + +**Starter prompt:** "Produce developer docs for the Strata design system: a color +reference (roles, light/dark, contrast notes), the type scale, spacing/radius, +component specs (button, tag, post card, Dig button, input), and per-platform +'getting started' pages that mirror `HANDOFF.md`. Export the guidelines to PDF." + +**Deliverables:** hosted docs site or PDF, per-platform quick-starts, changelog +convention tied to `tokens.json` versions. + +--- + +## 8 · Marketing Team + +**Provide:** +- `logo/logo-icon.svg` (primary, scalable) + `logo/logo-icon-master.png` +- `theme/tokens.json` (brand colors + type) + `guidelines/brand-guidelines.html` +- `icons/` (for store listings, social avatars, favicons) + +**CLAUDE.md pointers:** "Marketing may use the brand colors expressively but must +keep the mark rules (clear space ≥25%, sand or near-black backgrounds only, never +recolor). Headlines in Space Grotesk, body in Manrope, timestamps/labels in +JetBrains Mono. The tagline is 'Ordering streams across the chaos.'" + +**Starter prompt:** "Using the Strata palette and fonts, design launch assets: +social avatars/banners from `logo-icon.svg`, an app-store screenshot set that +matches the in-app look, and a one-page brand overview. Keep to the token colors +and the mark usage rules." + +**Deliverables:** social kit (avatars/banners), store screenshots, launch +one-pager — all on-brand. + +--- + +### File-to-team quick map + +| File | Web | iOS | macOS | Win | Linux | Android | Docs | Mktg | +|---|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:| +| tokens.json | ● | ● | ● | ● | ● | ● | ● | ● | +| interlinedlist-theme.css | ● | | | ○ | ○ | | ● | | +| ios-ILColor.swift | | ● | ● | | | | ● | | +| android-colors.xml / .kt | | | | | | ● | ● | | +| logo-icon.svg | ● | ● | ● | ● | ● | ● | ● | ● | +| icons/web | ● | | | | | | ● | ● | +| icons/ios | | ● | | | | | ● | | +| InterlinedList.icns | | | ● | | | | ● | | +| InterlinedList.ico | | | | ● | | | ● | | +| icons/linux | | | | | ● | | ● | | +| icons/android | | | | | | ● | ● | | +| guidelines.html | ● | ● | ● | ● | ● | ● | ● | ● | + +● = provide · ○ = provide if the app is web-tech (Electron/WebView) diff --git a/brand-kit/README.md b/brand-kit/README.md new file mode 100644 index 0000000..fc1c475 --- /dev/null +++ b/brand-kit/README.md @@ -0,0 +1,74 @@ +# InterlinedList — Brand Kit ("Strata") + +Everything the web, iOS, macOS, Windows, Linux, and Android teams need to +re-brand InterlinedList on a shared visual system. + +> **Direction:** Strata — deep-teal structure, green as the action color, +> amber for live/Dig, on a soft-sand light theme and a near-black dark theme. +> Type: **Space Grotesk** (display) · **Manrope** (body) · **JetBrains Mono** (time/meta). + +--- + +## Contents + +``` +brand-kit/ +├─ README.md ← you are here +├─ HANDOFF.md ← what to give each team + Claude starter prompts +├─ logo/ +│ ├─ logo-icon.svg ← vector mark (crisp at any size) +│ └─ logo-icon-master.png ← 321px master raster (transparent) +├─ icons/ ← export-ready app icons + packaged bundles +│ ├─ ios/ AppIcon-*.png (1024→40) + dark 1024 +│ ├─ macos/ icon-*.png (1024→16) + InterlinedList.icns (ready) +│ ├─ android/ ic_launcher-*.png + adaptive fg/bg layers +│ ├─ windows/ icon-*.png (256→16) + InterlinedList.ico (ready) +│ ├─ linux/ icon-*.png (512→48) hicolor set +│ └─ web/ favicon 16/32/48, apple-touch-180, 192, 512, maskable, dark +├─ theme/ +│ ├─ tokens.json ← source of truth for all platforms +│ ├─ interlinedlist-theme.css ← web: CSS variables + prefers-color-scheme + components +│ ├─ android-colors.xml ← Android View system (values / values-night) +│ ├─ android-Color.kt ← Android Jetpack Compose +│ └─ ios-ILColor.swift ← iOS / macOS SwiftUI +└─ guidelines/ + └─ brand-guidelines.html ← printable one-doc reference (open in a browser → Print → PDF) +``` + +## Color roles + +| Token | Hex (light / dark) | Use | +|---------|--------------------------|-----| +| Green | `#2FA877` / `#3FBF8C` | Primary action — Post, Compose, Follow | +| Teal | `#184860` / `#0C2C3A` | Structure — masthead, rails, card edges | +| Amber | `#F0A830` | Live status, Digs, highlights | +| Base | `#F4EEE2` / `#121317` | App background | +| Surface | `#FBF7EF` / `#17191F` | Cards, panels | +| Text | `#16323C` / `#F3F1EA` | Primary text | + +## Per-team quick start + +- **Web** — link `theme/interlinedlist-theme.css`; it exposes `--il-*` variables and + optional `.il-*` component classes. Dark mode follows the OS via + `prefers-color-scheme`; force it with ``. + Load the three Google Fonts. Wire the favicons/manifest from `icons/web/`. +- **iOS / macOS** — add `theme/ios-ILColor.swift`; register the three fonts. + Use `icons/ios` in the Asset Catalog; drop in `icons/macos/InterlinedList.icns` for the app icon. +- **Android** — split `android-colors.xml` into `values/` + `values-night/`, + or use `android-Color.kt` with Compose. Import the adaptive layers from + `icons/android` (`ic_launcher_foreground` + `ic_launcher_background`). +- **Windows** — wire `icons/windows/InterlinedList.ico` (multi-size 16–256) as the app/exe icon. +- **Linux** — install `icons/linux` into the hicolor theme dirs + `logo/logo-icon.svg` as the scalable icon. + +> **New teammate?** See `HANDOFF.md` for exactly which files to give each team +> and copy-paste Claude starter prompts. + +## Rules of the mark + +- Keep clear space around the mark ≥ 25% of its width. +- App icons sit on the **soft-sand tile** so all three mark colors read; the + near-black tile ships for dark home screens. Don't place the mark directly + on saturated teal (the teal strokes disappear). +- Wordmark: "Interlined" in text color, "List" in `#4FD09C` (dark) / `#2FA877` (light). + +_See `guidelines/brand-guidelines.html` for the full visual reference._ diff --git a/brand-kit/guidelines/brand-guidelines.html b/brand-kit/guidelines/brand-guidelines.html new file mode 100644 index 0000000..974a952 --- /dev/null +++ b/brand-kit/guidelines/brand-guidelines.html @@ -0,0 +1,179 @@ + + + + + +InterlinedList — Brand Guidelines (Strata) + + + + + + +
+ + +
+

Brand Guidelines

+
+ InterlinedList +

InterlinedList

+
+

Ordering streams across the chaos

+

The Strata system: deep-teal structure, green as the action color, amber for live & Dig moments, over a soft-sand light theme and a near-black dark theme. This document is the visual reference; machine-readable tokens live in theme/tokens.json.

+
+ + +
+

01 · The mark

+

Logo

+

The mark braids three streams — teal, green, amber — into a single ordered form. Use the master at logo/logo-icon-master.png. Preserve clear space of at least 25% of the mark's width on all sides.

+
+
On sand · preferred
+
On near-black · dark
+
InterlinedList
+
+
+
Do — keep the mark on sand or near-black; pair the wordmark with the "List" accent color.
+
Don't — place the mark on saturated teal (the teal strokes vanish), recolor it, or stretch it.
+
+
+ + +
+

02 · Color

+

Brand palette

+

Three brand colors drawn from the mark, plus theme neutrals. Green leads action; teal builds structure; amber marks what's live.

+
+
Green#2FA877
Primary action
+
Teal#184860
Structure / masthead
+
Amber#F0A830
Live / Dig / highlight
+
+
+
Sand base#F4EEE2
Light background
+
Surface#FBF7EF
Light cards
+
Near-black#121317
Dark background
+
Deep teal#0C2C3A
Dark masthead
+
+ + + + + + +
RoleLightDark
Primary action#2FA877#3FBF8C
Text#16323C#F3F1EA
Link#184860#7FB8C4
Borderrgba(24,72,96,.12)rgba(255,255,255,.08)
+
+ + +
+

03 · Typography

+

Type system

+
+
Space Grotesk
Display · wordmark · headings · 700/600
+
Manrope
Body · UI · reading · 400/500/600
+
JetBrains Mono
Timestamps · counts · meta
+
+ + + + + + +
StyleFont / weightSize
DisplaySpace Grotesk 70028 / 44
TitleSpace Grotesk 70017
BodyManrope 40013
MetaJetBrains Mono 50010
+
+ + +
+

04 · System

+

Spacing, radius & components

+
+
+
Spacing · 4pt grid
+
+ +
+
4 · 8 · 12 · 16 · 20 · 24 · 32 · 40
+
Radius · sharp
+
+
3
+
4
+
10
+
+
+
+
Buttons
+
PostFollowDisabled
+
Tags & Dig
+
+ #dev + #sync + #meta + ✦ 12 Digs +
+
+
+
+ + +
+

05 · App icons

+

Platform icons

+

The mark on a soft-sand tile so all three colors read; near-black variants ship for dark home screens. Full export set in icons/.

+
+
iOS
+
macOS
+
Android
+
Windows
+
Linux
+
web
+
+
+ +
+

InterlinedList — Strata brand kit · tokens: theme/tokens.json · questions → design

+
+ +
+ + diff --git a/brand-kit/icons/android/ic_launcher-144.png b/brand-kit/icons/android/ic_launcher-144.png new file mode 100644 index 0000000..4b91d54 Binary files /dev/null and b/brand-kit/icons/android/ic_launcher-144.png differ diff --git a/brand-kit/icons/android/ic_launcher-192.png b/brand-kit/icons/android/ic_launcher-192.png new file mode 100644 index 0000000..1cb6ff9 Binary files /dev/null and b/brand-kit/icons/android/ic_launcher-192.png differ diff --git a/brand-kit/icons/android/ic_launcher-48.png b/brand-kit/icons/android/ic_launcher-48.png new file mode 100644 index 0000000..cdd6aef Binary files /dev/null and b/brand-kit/icons/android/ic_launcher-48.png differ diff --git a/brand-kit/icons/android/ic_launcher-512.png b/brand-kit/icons/android/ic_launcher-512.png new file mode 100644 index 0000000..5b1b8d3 Binary files /dev/null and b/brand-kit/icons/android/ic_launcher-512.png differ diff --git a/brand-kit/icons/android/ic_launcher-72.png b/brand-kit/icons/android/ic_launcher-72.png new file mode 100644 index 0000000..59a4d66 Binary files /dev/null and b/brand-kit/icons/android/ic_launcher-72.png differ diff --git a/brand-kit/icons/android/ic_launcher-96.png b/brand-kit/icons/android/ic_launcher-96.png new file mode 100644 index 0000000..02d864c Binary files /dev/null and b/brand-kit/icons/android/ic_launcher-96.png differ diff --git a/brand-kit/icons/android/ic_launcher_background-512.png b/brand-kit/icons/android/ic_launcher_background-512.png new file mode 100644 index 0000000..df0ab8a Binary files /dev/null and b/brand-kit/icons/android/ic_launcher_background-512.png differ diff --git a/brand-kit/icons/android/ic_launcher_foreground-108.png b/brand-kit/icons/android/ic_launcher_foreground-108.png new file mode 100644 index 0000000..51b681a Binary files /dev/null and b/brand-kit/icons/android/ic_launcher_foreground-108.png differ diff --git a/brand-kit/icons/android/ic_launcher_foreground-192.png b/brand-kit/icons/android/ic_launcher_foreground-192.png new file mode 100644 index 0000000..a747b62 Binary files /dev/null and b/brand-kit/icons/android/ic_launcher_foreground-192.png differ diff --git a/brand-kit/icons/android/ic_launcher_foreground-512.png b/brand-kit/icons/android/ic_launcher_foreground-512.png new file mode 100644 index 0000000..18432da Binary files /dev/null and b/brand-kit/icons/android/ic_launcher_foreground-512.png differ diff --git a/brand-kit/icons/ios/AppIcon-1024.png b/brand-kit/icons/ios/AppIcon-1024.png new file mode 100644 index 0000000..1721481 Binary files /dev/null and b/brand-kit/icons/ios/AppIcon-1024.png differ diff --git a/brand-kit/icons/ios/AppIcon-120.png b/brand-kit/icons/ios/AppIcon-120.png new file mode 100644 index 0000000..b558017 Binary files /dev/null and b/brand-kit/icons/ios/AppIcon-120.png differ diff --git a/brand-kit/icons/ios/AppIcon-152.png b/brand-kit/icons/ios/AppIcon-152.png new file mode 100644 index 0000000..5ae5cd9 Binary files /dev/null and b/brand-kit/icons/ios/AppIcon-152.png differ diff --git a/brand-kit/icons/ios/AppIcon-167.png b/brand-kit/icons/ios/AppIcon-167.png new file mode 100644 index 0000000..058fc2e Binary files /dev/null and b/brand-kit/icons/ios/AppIcon-167.png differ diff --git a/brand-kit/icons/ios/AppIcon-180.png b/brand-kit/icons/ios/AppIcon-180.png new file mode 100644 index 0000000..abcd3ce Binary files /dev/null and b/brand-kit/icons/ios/AppIcon-180.png differ diff --git a/brand-kit/icons/ios/AppIcon-40.png b/brand-kit/icons/ios/AppIcon-40.png new file mode 100644 index 0000000..089d39b Binary files /dev/null and b/brand-kit/icons/ios/AppIcon-40.png differ diff --git a/brand-kit/icons/ios/AppIcon-60.png b/brand-kit/icons/ios/AppIcon-60.png new file mode 100644 index 0000000..cadb3d0 Binary files /dev/null and b/brand-kit/icons/ios/AppIcon-60.png differ diff --git a/brand-kit/icons/ios/AppIcon-80.png b/brand-kit/icons/ios/AppIcon-80.png new file mode 100644 index 0000000..0c3344a Binary files /dev/null and b/brand-kit/icons/ios/AppIcon-80.png differ diff --git a/brand-kit/icons/ios/AppIcon-87.png b/brand-kit/icons/ios/AppIcon-87.png new file mode 100644 index 0000000..52831d8 Binary files /dev/null and b/brand-kit/icons/ios/AppIcon-87.png differ diff --git a/brand-kit/icons/ios/AppIcon-dark-1024.png b/brand-kit/icons/ios/AppIcon-dark-1024.png new file mode 100644 index 0000000..bb67c9c Binary files /dev/null and b/brand-kit/icons/ios/AppIcon-dark-1024.png differ diff --git a/brand-kit/icons/linux/icon-128.png b/brand-kit/icons/linux/icon-128.png new file mode 100644 index 0000000..9414a9b Binary files /dev/null and b/brand-kit/icons/linux/icon-128.png differ diff --git a/brand-kit/icons/linux/icon-256.png b/brand-kit/icons/linux/icon-256.png new file mode 100644 index 0000000..24b8158 Binary files /dev/null and b/brand-kit/icons/linux/icon-256.png differ diff --git a/brand-kit/icons/linux/icon-48.png b/brand-kit/icons/linux/icon-48.png new file mode 100644 index 0000000..8a899bf Binary files /dev/null and b/brand-kit/icons/linux/icon-48.png differ diff --git a/brand-kit/icons/linux/icon-512.png b/brand-kit/icons/linux/icon-512.png new file mode 100644 index 0000000..3ee9d27 Binary files /dev/null and b/brand-kit/icons/linux/icon-512.png differ diff --git a/brand-kit/icons/linux/icon-64.png b/brand-kit/icons/linux/icon-64.png new file mode 100644 index 0000000..4aacee7 Binary files /dev/null and b/brand-kit/icons/linux/icon-64.png differ diff --git a/brand-kit/icons/macos/InterlinedList.icns b/brand-kit/icons/macos/InterlinedList.icns new file mode 100644 index 0000000..9ba7b06 Binary files /dev/null and b/brand-kit/icons/macos/InterlinedList.icns differ diff --git a/brand-kit/icons/macos/icon-1024.png b/brand-kit/icons/macos/icon-1024.png new file mode 100644 index 0000000..d25cdc6 Binary files /dev/null and b/brand-kit/icons/macos/icon-1024.png differ diff --git a/brand-kit/icons/macos/icon-128.png b/brand-kit/icons/macos/icon-128.png new file mode 100644 index 0000000..f5fa4bc Binary files /dev/null and b/brand-kit/icons/macos/icon-128.png differ diff --git a/brand-kit/icons/macos/icon-16.png b/brand-kit/icons/macos/icon-16.png new file mode 100644 index 0000000..9d08c7b Binary files /dev/null and b/brand-kit/icons/macos/icon-16.png differ diff --git a/brand-kit/icons/macos/icon-256.png b/brand-kit/icons/macos/icon-256.png new file mode 100644 index 0000000..a6066d3 Binary files /dev/null and b/brand-kit/icons/macos/icon-256.png differ diff --git a/brand-kit/icons/macos/icon-32.png b/brand-kit/icons/macos/icon-32.png new file mode 100644 index 0000000..1087278 Binary files /dev/null and b/brand-kit/icons/macos/icon-32.png differ diff --git a/brand-kit/icons/macos/icon-512.png b/brand-kit/icons/macos/icon-512.png new file mode 100644 index 0000000..576f94d Binary files /dev/null and b/brand-kit/icons/macos/icon-512.png differ diff --git a/brand-kit/icons/macos/icon-64.png b/brand-kit/icons/macos/icon-64.png new file mode 100644 index 0000000..40126c8 Binary files /dev/null and b/brand-kit/icons/macos/icon-64.png differ diff --git a/brand-kit/icons/macos/icon-dark-1024.png b/brand-kit/icons/macos/icon-dark-1024.png new file mode 100644 index 0000000..d4a3900 Binary files /dev/null and b/brand-kit/icons/macos/icon-dark-1024.png differ diff --git a/brand-kit/icons/web/apple-touch-icon-180.png b/brand-kit/icons/web/apple-touch-icon-180.png new file mode 100644 index 0000000..abcd3ce Binary files /dev/null and b/brand-kit/icons/web/apple-touch-icon-180.png differ diff --git a/brand-kit/icons/web/favicon-16.png b/brand-kit/icons/web/favicon-16.png new file mode 100644 index 0000000..77ae826 Binary files /dev/null and b/brand-kit/icons/web/favicon-16.png differ diff --git a/brand-kit/icons/web/favicon-32.png b/brand-kit/icons/web/favicon-32.png new file mode 100644 index 0000000..116604c Binary files /dev/null and b/brand-kit/icons/web/favicon-32.png differ diff --git a/brand-kit/icons/web/favicon-48.png b/brand-kit/icons/web/favicon-48.png new file mode 100644 index 0000000..b82d1ce Binary files /dev/null and b/brand-kit/icons/web/favicon-48.png differ diff --git a/brand-kit/icons/web/favicon-dark-32.png b/brand-kit/icons/web/favicon-dark-32.png new file mode 100644 index 0000000..557c063 Binary files /dev/null and b/brand-kit/icons/web/favicon-dark-32.png differ diff --git a/brand-kit/icons/web/icon-192.png b/brand-kit/icons/web/icon-192.png new file mode 100644 index 0000000..10bb18f Binary files /dev/null and b/brand-kit/icons/web/icon-192.png differ diff --git a/brand-kit/icons/web/icon-512.png b/brand-kit/icons/web/icon-512.png new file mode 100644 index 0000000..cc0f720 Binary files /dev/null and b/brand-kit/icons/web/icon-512.png differ diff --git a/brand-kit/icons/web/maskable-512.png b/brand-kit/icons/web/maskable-512.png new file mode 100644 index 0000000..dbd64ec Binary files /dev/null and b/brand-kit/icons/web/maskable-512.png differ diff --git a/brand-kit/icons/windows/InterlinedList.ico b/brand-kit/icons/windows/InterlinedList.ico new file mode 100644 index 0000000..e806c07 Binary files /dev/null and b/brand-kit/icons/windows/InterlinedList.ico differ diff --git a/brand-kit/icons/windows/icon-16.png b/brand-kit/icons/windows/icon-16.png new file mode 100644 index 0000000..fa4abd8 Binary files /dev/null and b/brand-kit/icons/windows/icon-16.png differ diff --git a/brand-kit/icons/windows/icon-256.png b/brand-kit/icons/windows/icon-256.png new file mode 100644 index 0000000..5f7169b Binary files /dev/null and b/brand-kit/icons/windows/icon-256.png differ diff --git a/brand-kit/icons/windows/icon-32.png b/brand-kit/icons/windows/icon-32.png new file mode 100644 index 0000000..3e40c7b Binary files /dev/null and b/brand-kit/icons/windows/icon-32.png differ diff --git a/brand-kit/icons/windows/icon-48.png b/brand-kit/icons/windows/icon-48.png new file mode 100644 index 0000000..a8299a3 Binary files /dev/null and b/brand-kit/icons/windows/icon-48.png differ diff --git a/brand-kit/icons/windows/icon-64.png b/brand-kit/icons/windows/icon-64.png new file mode 100644 index 0000000..0f45ac2 Binary files /dev/null and b/brand-kit/icons/windows/icon-64.png differ diff --git a/brand-kit/logo/logo-icon-master.png b/brand-kit/logo/logo-icon-master.png new file mode 100644 index 0000000..83b4250 Binary files /dev/null and b/brand-kit/logo/logo-icon-master.png differ diff --git a/brand-kit/logo/logo-icon.svg b/brand-kit/logo/logo-icon.svg new file mode 100644 index 0000000..49d4817 --- /dev/null +++ b/brand-kit/logo/logo-icon.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/brand-kit/theme/android-Color.kt b/brand-kit/theme/android-Color.kt new file mode 100644 index 0000000..f0e44e1 --- /dev/null +++ b/brand-kit/theme/android-Color.kt @@ -0,0 +1,30 @@ +// InterlinedList — Strata · Jetpack Compose color tokens +package com.interlinedlist.theme + +import androidx.compose.ui.graphics.Color + +// Brand (constant) +val ILGreen = Color(0xFF2FA877) +val ILGreenHover = Color(0xFF28936A) +val ILGreenDark = Color(0xFF3FBF8C) +val ILTeal = Color(0xFF184860) +val ILTealDeep = Color(0xFF0C2C3A) +val ILTealAccent = Color(0xFF7FB8C4) +val ILTealBright = Color(0xFF4FD09C) +val ILAmber = Color(0xFFF0A830) + +// Light +val ILBgLight = Color(0xFFF4EEE2) +val ILSurfaceLight = Color(0xFFFBF7EF) +val ILSurface2Light = Color(0xFFF6F1E7) +val ILTextLight = Color(0xFF16323C) +val ILTextBodyLight = Color(0xFF22383E) +val ILMastheadLight = Color(0xFF184860) + +// Dark +val ILBgDark = Color(0xFF121317) +val ILSurfaceDark = Color(0xFF17191F) +val ILSurface2Dark = Color(0xFF1B1D23) +val ILTextDark = Color(0xFFF3F1EA) +val ILTextBodyDark = Color(0xFFE4E1D9) +val ILMastheadDark = Color(0xFF0C2C3A) diff --git a/brand-kit/theme/android-colors.xml b/brand-kit/theme/android-colors.xml new file mode 100644 index 0000000..227c307 --- /dev/null +++ b/brand-kit/theme/android-colors.xml @@ -0,0 +1,34 @@ + + + + + #FF2FA877 + #FF28936A + #FF3FBF8C + #FF184860 + #FF0C2C3A + #FF7FB8C4 + #FF4FD09C + #FFF0A830 + #FFDB9720 + + + #FFF4EEE2 + #FFFBF7EF + #FFF6F1E7 + #FF16323C + #FF22383E + #8C184860 + #FF184860 + + + #FF121317 + #FF17191F + #FF1B1D23 + #FFF3F1EA + #D9F3F1EA + #73F3F1EA + #FF0C2C3A + diff --git a/brand-kit/theme/interlinedlist-theme.css b/brand-kit/theme/interlinedlist-theme.css new file mode 100644 index 0000000..088376f --- /dev/null +++ b/brand-kit/theme/interlinedlist-theme.css @@ -0,0 +1,159 @@ +/* ============================================================ + InterlinedList — "Strata" theme + Drop-in CSS custom properties for the web rebrand. + Light = soft sand · Dark = near-black. Follows the OS via + prefers-color-scheme, with a manual [data-theme] override. + ============================================================ */ + +:root { + /* ---- Brand (constant across themes) ---- */ + --il-green: #2FA877; /* primary action */ + --il-green-hover: #28936A; + --il-teal: #184860; /* structure / masthead */ + --il-teal-deep: #0C2C3A; /* dark masthead */ + --il-teal-accent: #7FB8C4; /* links / borders in dark */ + --il-teal-bright: #4FD09C; /* wordmark accent */ + --il-amber: #F0A830; /* live / Dig / highlight */ + --il-amber-hover: #DB9720; + + /* ---- Typography ---- */ + --il-font-display: 'Space Grotesk', system-ui, sans-serif; + --il-font-body: 'Manrope', system-ui, sans-serif; + --il-font-mono: 'JetBrains Mono', ui-monospace, monospace; + --il-fs-display: 28px; --il-fs-title: 17px; --il-fs-subtitle: 15px; + --il-fs-body: 13px; --il-fs-small: 12px; --il-fs-meta: 10px; --il-fs-micro: 9px; + + /* ---- Spacing · 4pt grid ---- */ + --il-space-1: 4px; --il-space-2: 8px; --il-space-3: 12px; --il-space-4: 16px; + --il-space-5: 20px; --il-space-6: 24px; --il-space-8: 32px; --il-space-10: 40px; + + /* ---- Radius · sharp language ---- */ + --il-radius-sm: 3px; /* controls, tags */ + --il-radius-md: 4px; /* cards, panels */ + --il-radius-lg: 10px; /* desktop windows */ + --il-radius-pill: 999px; + + /* ---- Elevation ---- */ + --il-shadow-card: 0 1px 3px rgba(24,72,96,.06); + --il-shadow-float: 0 10px 34px rgba(24,72,96,.14); +} + +/* ---- LIGHT (default) ---- */ +:root, [data-theme="light"] { + --il-bg: #F4EEE2; + --il-surface: #FBF7EF; + --il-surface-2: #F6F1E7; + --il-surface-3: #F1EADD; + --il-border: rgba(24,72,96,.12); + --il-border-strong: rgba(24,72,96,.20); + --il-text: #16323C; + --il-text-body: #22383E; + --il-text-muted: rgba(24,72,96,.55); + --il-masthead: #184860; + --il-on-masthead:#FFFFFF; + --il-primary: var(--il-green); + --il-link: var(--il-teal); +} + +/* ---- DARK (follows OS) ---- */ +@media (prefers-color-scheme: dark) { + :root { + --il-bg: #121317; + --il-surface: #17191F; + --il-surface-2: #1B1D23; + --il-surface-3: #22252D; + --il-border: rgba(255,255,255,.08); + --il-border-strong: rgba(255,255,255,.16); + --il-text: #F3F1EA; + --il-text-body: rgba(243,241,234,.85); + --il-text-muted: rgba(243,241,234,.45); + --il-masthead: #0C2C3A; + --il-on-masthead:#F3F1EA; + --il-primary: var(--il-teal-bright); + --il-link: var(--il-teal-accent); + --il-shadow-card: 0 1px 3px rgba(0,0,0,.4); + --il-shadow-float: 0 10px 34px rgba(0,0,0,.5); + } +} + +/* ---- DARK (manual override, wins over OS) ---- */ +[data-theme="dark"] { + --il-bg: #121317; + --il-surface: #17191F; + --il-surface-2: #1B1D23; + --il-surface-3: #22252D; + --il-border: rgba(255,255,255,.08); + --il-border-strong: rgba(255,255,255,.16); + --il-text: #F3F1EA; + --il-text-body: rgba(243,241,234,.85); + --il-text-muted: rgba(243,241,234,.45); + --il-masthead: #0C2C3A; + --il-on-masthead:#F3F1EA; + --il-primary: var(--il-teal-bright); + --il-link: var(--il-teal-accent); + --il-shadow-card: 0 1px 3px rgba(0,0,0,.4); + --il-shadow-float: 0 10px 34px rgba(0,0,0,.5); +} + +/* ============================================================ + Component primitives — optional starting classes + ============================================================ */ + +body { + background: var(--il-bg); + color: var(--il-text-body); + font-family: var(--il-font-body); + -webkit-font-smoothing: antialiased; +} + +.il-wordmark { font-family: var(--il-font-display); font-weight: 700; letter-spacing: -.01em; color: var(--il-text); } +.il-wordmark span { color: var(--il-teal-bright); } /* the "List" accent */ + +.il-masthead { + display: flex; align-items: center; gap: var(--il-space-4); + padding: var(--il-space-3) var(--il-space-5); + background: var(--il-masthead); color: var(--il-on-masthead); +} + +.il-card { + background: var(--il-surface); + border: 1px solid var(--il-border); + border-radius: var(--il-radius-md); + box-shadow: var(--il-shadow-card); +} + +/* Post — color-edged card is the Strata signature */ +.il-post { border-left: 3px solid var(--il-teal); padding: var(--il-space-4) var(--il-space-5); } +.il-post--reply { border-left-color: var(--il-green); } +.il-post--release { border-left-color: var(--il-amber); } + +.il-btn { + font-family: var(--il-font-display); font-weight: 600; font-size: var(--il-fs-small); + padding: 8px 16px; border-radius: var(--il-radius-sm); border: 0; cursor: pointer; +} +.il-btn--primary { background: var(--il-primary); color: #fff; } +.il-btn--primary:hover { background: var(--il-green-hover); } +.il-btn--ghost { background: transparent; color: var(--il-primary); border: 1px solid var(--il-primary); } +.il-btn:disabled { background: var(--il-border); color: var(--il-text-muted); cursor: not-allowed; } + +.il-tag { + font-family: var(--il-font-mono); font-weight: 500; font-size: var(--il-fs-micro); + padding: 4px 8px; border-radius: var(--il-radius-sm); + color: var(--il-teal); background: rgba(24,72,96,.09); +} +.il-tag--green { color: var(--il-green); background: rgba(47,168,119,.12); } +.il-tag--amber { color: var(--il-amber); background: rgba(240,168,48,.14); } + +.il-meta { font-family: var(--il-font-mono); font-size: var(--il-fs-meta); color: var(--il-text-muted); } + +/* Dig button */ +.il-dig { font-family: var(--il-font-mono); font-size: var(--il-fs-meta); color: var(--il-text-muted); + border: 1px solid var(--il-border-strong); padding: 6px 11px; border-radius: var(--il-radius-sm); background: none; cursor: pointer; } +.il-dig--active { color: #fff; background: var(--il-amber); border-color: var(--il-amber); } + +/* Live status dot */ +.il-live { display: inline-flex; align-items: center; gap: 6px; font-family: var(--il-font-mono); + font-size: var(--il-fs-meta); font-weight: 600; color: var(--il-amber); } +.il-live::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--il-amber); + animation: il-pulse 2s infinite; } +@keyframes il-pulse { 0%,100% { opacity: 1 } 50% { opacity: .35 } } diff --git a/brand-kit/theme/ios-ILColor.swift b/brand-kit/theme/ios-ILColor.swift new file mode 100644 index 0000000..7927b9b --- /dev/null +++ b/brand-kit/theme/ios-ILColor.swift @@ -0,0 +1,54 @@ +// InterlinedList — Strata · iOS / macOS color tokens +// Colors adapt automatically to light / dark via UITraitCollection. +// For SwiftUI. Fonts: register Space Grotesk, Manrope, JetBrains Mono. + +import SwiftUI + +public enum ILColor { + // MARK: Brand (constant) + public static let green = Color(hex: 0x2FA877) // primary action + public static let greenHover = Color(hex: 0x28936A) + public static let greenDark = Color(hex: 0x3FBF8C) // primary in dark + public static let teal = Color(hex: 0x184860) // structure / masthead + public static let tealDeep = Color(hex: 0x0C2C3A) // dark masthead + public static let tealAccent = Color(hex: 0x7FB8C4) // links in dark + public static let tealBright = Color(hex: 0x4FD09C) // wordmark accent + public static let amber = Color(hex: 0xF0A830) // live / Dig + + // MARK: Theme-adaptive (light / dark) + public static let background = dynamic(light: 0xF4EEE2, dark: 0x121317) + public static let surface = dynamic(light: 0xFBF7EF, dark: 0x17191F) + public static let surface2 = dynamic(light: 0xF6F1E7, dark: 0x1B1D23) + public static let text = dynamic(light: 0x16323C, dark: 0xF3F1EA) + public static let textBody = dynamic(light: 0x22383E, dark: 0xE4E1D9) + public static let masthead = dynamic(light: 0x184860, dark: 0x0C2C3A) + public static let primary = dynamic(light: 0x2FA877, dark: 0x3FBF8C) + public static let link = dynamic(light: 0x184860, dark: 0x7FB8C4) + + private static func dynamic(light: UInt, dark: UInt) -> Color { + Color(UIColor { $0.userInterfaceStyle == .dark + ? UIColor(rgb: dark) : UIColor(rgb: light) }) + } +} + +public enum ILType { + public static let display = "SpaceGrotesk-Bold" + public static let title = "SpaceGrotesk-Bold" + public static let body = "Manrope-Regular" + public static let mono = "JetBrainsMono-Medium" +} + +public enum ILMetric { + public static let radiusSm: CGFloat = 3, radiusMd: CGFloat = 4, radiusLg: CGFloat = 10 + public static let space: [CGFloat] = [4, 8, 12, 16, 20, 24, 32, 40, 48] +} + +// Helpers +extension Color { init(hex: UInt) { + self.init(.sRGB, red: Double((hex >> 16) & 0xFF)/255, + green: Double((hex >> 8) & 0xFF)/255, + blue: Double(hex & 0xFF)/255) } } +extension UIColor { convenience init(rgb: UInt) { + self.init(red: CGFloat((rgb >> 16) & 0xFF)/255, + green: CGFloat((rgb >> 8) & 0xFF)/255, + blue: CGFloat(rgb & 0xFF)/255, alpha: 1) } } diff --git a/brand-kit/theme/tokens.json b/brand-kit/theme/tokens.json new file mode 100644 index 0000000..f9ead82 --- /dev/null +++ b/brand-kit/theme/tokens.json @@ -0,0 +1,42 @@ +{ + "$name": "InterlinedList — Strata", + "$description": "Cross-platform design tokens. Brand colors derive from the mark; light = soft sand, dark = near-black.", + "brand": { + "green": { "value": "#2FA877", "role": "primary action" }, + "greenHover": { "value": "#28936A" }, + "greenDark": { "value": "#3FBF8C", "role": "primary in dark mode" }, + "teal": { "value": "#184860", "role": "structure / masthead" }, + "tealDeep": { "value": "#0C2C3A", "role": "dark masthead" }, + "tealAccent": { "value": "#7FB8C4", "role": "links / borders in dark" }, + "tealBright": { "value": "#4FD09C", "role": "wordmark accent" }, + "amber": { "value": "#F0A830", "role": "live / Dig / highlight" }, + "amberHover": { "value": "#DB9720" } + }, + "theme": { + "light": { + "bg": "#F4EEE2", "surface": "#FBF7EF", "surface2": "#F6F1E7", "surface3": "#F1EADD", + "border": "rgba(24,72,96,0.12)", "borderStrong": "rgba(24,72,96,0.20)", + "text": "#16323C", "textBody": "#22383E", "textMuted": "rgba(24,72,96,0.55)", + "masthead": "#184860", "onMasthead": "#FFFFFF", "primary": "#2FA877", "link": "#184860" + }, + "dark": { + "bg": "#121317", "surface": "#17191F", "surface2": "#1B1D23", "surface3": "#22252D", + "border": "rgba(255,255,255,0.08)", "borderStrong": "rgba(255,255,255,0.16)", + "text": "#F3F1EA", "textBody": "rgba(243,241,234,0.85)", "textMuted": "rgba(243,241,234,0.45)", + "masthead": "#0C2C3A", "onMasthead": "#F3F1EA", "primary": "#3FBF8C", "link": "#7FB8C4" + } + }, + "typography": { + "fontDisplay": "Space Grotesk", + "fontBody": "Manrope", + "fontMono": "JetBrains Mono", + "scale": { "display": 28, "title": 17, "subtitle": 15, "body": 13, "small": 12, "meta": 10, "micro": 9 }, + "weights": [400, 500, 600, 700] + }, + "spacing": { "unit": 4, "scale": [4, 8, 12, 16, 20, 24, 32, 40, 48] }, + "radius": { "sm": 3, "md": 4, "lg": 10, "pill": 999 }, + "elevation": { + "card": "0 1px 3px rgba(24,72,96,0.06)", + "float": "0 10px 34px rgba(24,72,96,0.14)" + } +} diff --git a/core/datastore/src/main/kotlin/com/interlinedlist/android/core/datastore/ThemeSettingsStore.kt b/core/datastore/src/main/kotlin/com/interlinedlist/android/core/datastore/ThemeSettingsStore.kt new file mode 100644 index 0000000..32f4e9a --- /dev/null +++ b/core/datastore/src/main/kotlin/com/interlinedlist/android/core/datastore/ThemeSettingsStore.kt @@ -0,0 +1,50 @@ +package com.interlinedlist.android.core.datastore + +import android.content.Context +import android.content.SharedPreferences +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject +import javax.inject.Singleton + +/** The user's chosen app appearance. [SYSTEM] follows the OS light/dark setting. */ +enum class ThemeMode { + SYSTEM, + LIGHT, + DARK, +} + +/** + * Persists the user's appearance preference (System / Light / Dark) in plain + * SharedPreferences — it carries no secrets, so it does not need the encrypted + * session store. Exposes the current value as a [StateFlow] so the theme + * recomposes the moment the setting changes. + */ +@Singleton +class ThemeSettingsStore @Inject constructor( + @ApplicationContext context: Context, +) { + private val prefs: SharedPreferences = + context.getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE) + + private val _themeMode = MutableStateFlow(readThemeMode()) + val themeMode: StateFlow = _themeMode.asStateFlow() + + /** Persists [mode] and pushes it to observers immediately. */ + fun setThemeMode(mode: ThemeMode) { + prefs.edit().putString(KEY_THEME_MODE, mode.name).apply() + _themeMode.value = mode + } + + private fun readThemeMode(): ThemeMode { + val stored = prefs.getString(KEY_THEME_MODE, null) ?: return ThemeMode.SYSTEM + return runCatching { ThemeMode.valueOf(stored) }.getOrDefault(ThemeMode.SYSTEM) + } + + companion object { + private const val PREFS_FILE = "il_settings.prefs" + private const val KEY_THEME_MODE = "theme_mode" + } +} diff --git a/core/designsystem/src/main/kotlin/com/interlinedlist/android/core/designsystem/component/Logo.kt b/core/designsystem/src/main/kotlin/com/interlinedlist/android/core/designsystem/component/Logo.kt index 890daba..9a7d87f 100644 --- a/core/designsystem/src/main/kotlin/com/interlinedlist/android/core/designsystem/component/Logo.kt +++ b/core/designsystem/src/main/kotlin/com/interlinedlist/android/core/designsystem/component/Logo.kt @@ -13,21 +13,24 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp import com.interlinedlist.android.core.designsystem.R -import com.interlinedlist.android.core.designsystem.theme.EmeraldGreen -import com.interlinedlist.android.core.designsystem.theme.OceanBlue +import com.interlinedlist.android.core.designsystem.theme.ILGreen +import com.interlinedlist.android.core.designsystem.theme.ILGreenDark +import com.interlinedlist.android.core.designsystem.theme.ILTeal +import com.interlinedlist.android.core.designsystem.theme.ILTealAccent import com.interlinedlist.android.core.designsystem.theme.PlayFontFamily /** - * The InterlinedList icon mark. Picks the light or dark asset variant to suit - * the current theme, per the brand rule (light mark on dark surfaces and vice - * versa). The provided files are used as-is — never recoloured or distorted. + * The InterlinedList "Strata" icon mark (the interlinked list ladder). The tri-colour + * mark reads on either surface, but on dark the deep teal is lifted to the accent so + * the structure stays legible. The vector is the same art shipped on interlinedlist.com; + * never recoloured beyond this light/dark pairing or distorted. */ @Composable fun InterlinedListLogoMark( modifier: Modifier = Modifier, darkTheme: Boolean = isSystemInDarkTheme(), ) { - val markRes = if (darkTheme) R.drawable.il_mark_light else R.drawable.il_mark_dark + val markRes = if (darkTheme) R.drawable.il_logo_mark_dark else R.drawable.il_logo_mark Image( painter = painterResource(markRes), contentDescription = "InterlinedList", @@ -36,9 +39,8 @@ fun InterlinedListLogoMark( } /** - * The "InterlinedList" wordmark rendered in the brand typeface: Ocean Blue - * "Interlined" (switching to the theme's on-background colour in dark mode for - * legibility) + Emerald "List", with the optional tagline. + * The "InterlinedList" wordmark in the brand typeface: teal "Interlined" (lifted to the + * teal accent on dark surfaces for legibility) + green "List", with an optional tagline. */ @Composable fun InterlinedListWordmark( @@ -46,7 +48,8 @@ fun InterlinedListWordmark( showTagline: Boolean = true, darkTheme: Boolean = isSystemInDarkTheme(), ) { - val interlinedColor = if (darkTheme) MaterialTheme.colorScheme.onBackground else OceanBlue + val interlinedColor = if (darkTheme) ILTealAccent else ILTeal + val listColor = if (darkTheme) ILGreenDark else ILGreen Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) { Row { Text( @@ -61,7 +64,7 @@ fun InterlinedListWordmark( fontFamily = PlayFontFamily, fontWeight = FontWeight.Bold, fontSize = 28.sp, - color = EmeraldGreen, + color = listColor, ) } if (showTagline) { diff --git a/core/designsystem/src/main/kotlin/com/interlinedlist/android/core/designsystem/theme/Color.kt b/core/designsystem/src/main/kotlin/com/interlinedlist/android/core/designsystem/theme/Color.kt index 0951401..be492b3 100644 --- a/core/designsystem/src/main/kotlin/com/interlinedlist/android/core/designsystem/theme/Color.kt +++ b/core/designsystem/src/main/kotlin/com/interlinedlist/android/core/designsystem/theme/Color.kt @@ -2,19 +2,50 @@ package com.interlinedlist.android.core.designsystem.theme import androidx.compose.ui.graphics.Color -// Core brand palette — from the InterlinedList Branding & Style Guide. -val OceanBlue = Color(0xFF0F4C5F) // primary: headers, CTAs -val EmeraldGreen = Color(0xFF34A56D) // active states, success -val AmberGold = Color(0xFFF9AF36) // highlights, badges -val NearBlack = Color(0xFF1A1A1A) // dark backgrounds, body text -val BrandWhite = Color(0xFFFFFFFF) +// InterlinedList — "Strata" design tokens. Canonical source: brand-kit/theme/tokens.json +// (identical to the palette on interlinedlist.com). Light = soft sand, dark = near-black. -// Deep ocean tint used for elevated surfaces in dark theme. -val DeepOcean = Color(0xFF15303A) +// ---- Brand (constant across themes) ---- +val ILGreen = Color(0xFF2FA877) // primary action +val ILGreenHover = Color(0xFF28936A) +val ILGreenDark = Color(0xFF3FBF8C) // primary in dark mode +val ILTeal = Color(0xFF184860) // structure / masthead +val ILTealDeep = Color(0xFF0C2C3A) // dark masthead +val ILTealAccent = Color(0xFF7FB8C4) // links / borders in dark +val ILTealBright = Color(0xFF4FD09C) // wordmark accent +val ILAmber = Color(0xFFF0A830) // live / highlight +val ILAmberHover = Color(0xFFDB9720) +val ILError = Color(0xFFED321F) +val ILWhite = Color(0xFFFFFFFF) -// Extended palette. -val Violet = Color(0xFF7E67FE) -val ElectricBlue = Color(0xFF1A80F8) -val TealCyan = Color(0xFF1AB0F8) -val VividGreen = Color(0xFF21D760) -val AlertRed = Color(0xFFED321F) +// ---- Light theme surfaces & text ---- +val ILBgLight = Color(0xFFF4EEE2) // soft sand background +val ILSurfaceLight = Color(0xFFFBF7EF) +val ILSurface2Light = Color(0xFFF6F1E7) +val ILSurface3Light = Color(0xFFF1EADD) +val ILTextLight = Color(0xFF16323C) +val ILTextBodyLight = Color(0xFF22383E) +val ILTextMutedLight = Color(0xFF6B7C83) // ≈ rgba(24,72,96,0.55) over sand +val ILBorderLight = Color(0xFFDDD6C8) +// Tonal containers (FAB, tonal buttons, nav indicator) in brand hues. +val ILGreenContainerLight = Color(0xFFBDE8D4) +val ILOnGreenContainerLight = Color(0xFF0B4A34) +val ILTealContainerLight = Color(0xFFCFE0E4) +val ILAmberContainerLight = Color(0xFFFBE3BC) +val ILOnAmberContainerLight = Color(0xFF4A3305) + +// ---- Dark theme surfaces & text ---- +val ILBgDark = Color(0xFF121317) // near-black background +val ILSurfaceDark = Color(0xFF17191F) +val ILSurface2Dark = Color(0xFF1B1D23) +val ILSurface3Dark = Color(0xFF22252D) +val ILTextDark = Color(0xFFF3F1EA) +val ILTextBodyDark = Color(0xFFE4E1D9) +val ILTextMutedDark = Color(0xFF9AA1A6) // ≈ rgba(243,241,234,0.45) over near-black +val ILBorderDark = Color(0xFF2C2F37) +// Tonal containers for dark surfaces. +val ILGreenContainerDark = Color(0xFF1E5641) +val ILOnGreenContainerDark = Color(0xFFB6ECD6) +val ILTealContainerDark = Color(0xFF20343C) +val ILAmberContainerDark = Color(0xFF4E3A12) +val ILOnAmberContainerDark = Color(0xFFF7DDB0) diff --git a/core/designsystem/src/main/kotlin/com/interlinedlist/android/core/designsystem/theme/Theme.kt b/core/designsystem/src/main/kotlin/com/interlinedlist/android/core/designsystem/theme/Theme.kt index bc8b4e6..24d19a9 100644 --- a/core/designsystem/src/main/kotlin/com/interlinedlist/android/core/designsystem/theme/Theme.kt +++ b/core/designsystem/src/main/kotlin/com/interlinedlist/android/core/designsystem/theme/Theme.kt @@ -6,38 +6,66 @@ import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +// Light = soft sand surfaces, teal structure, green primary actions, amber highlights. private val LightColors = lightColorScheme( - primary = OceanBlue, - onPrimary = BrandWhite, - secondary = EmeraldGreen, - onSecondary = BrandWhite, - tertiary = AmberGold, - onTertiary = NearBlack, - background = BrandWhite, - onBackground = NearBlack, - surface = BrandWhite, - onSurface = NearBlack, - error = AlertRed, - onError = BrandWhite, + primary = ILGreen, + onPrimary = ILWhite, + primaryContainer = ILGreenContainerLight, + onPrimaryContainer = ILOnGreenContainerLight, + secondary = ILTeal, + onSecondary = ILWhite, + secondaryContainer = ILTealContainerLight, + onSecondaryContainer = ILTeal, + tertiary = ILAmber, + onTertiary = ILTextLight, + tertiaryContainer = ILAmberContainerLight, + onTertiaryContainer = ILOnAmberContainerLight, + background = ILBgLight, + onBackground = ILTextLight, + surface = ILSurfaceLight, + onSurface = ILTextLight, + surfaceVariant = ILSurface2Light, + onSurfaceVariant = ILTextMutedLight, + surfaceContainer = ILSurface2Light, + outline = ILBorderLight, + outlineVariant = ILBorderLight, + error = ILError, + onError = ILWhite, ) +// Dark = near-black surfaces; the deep teal lifts to accent and green brightens so +// both stay legible per the brand's "light mark on dark" rule. private val DarkColors = darkColorScheme( - // Emerald reads better than Ocean Blue as the accent on dark surfaces. - primary = EmeraldGreen, - onPrimary = NearBlack, - secondary = AmberGold, - onSecondary = NearBlack, - tertiary = TealCyan, - onTertiary = NearBlack, - background = NearBlack, - onBackground = BrandWhite, - surface = DeepOcean, - onSurface = BrandWhite, - error = AlertRed, - onError = NearBlack, + primary = ILGreenDark, + onPrimary = ILBgDark, + primaryContainer = ILGreenContainerDark, + onPrimaryContainer = ILOnGreenContainerDark, + secondary = ILTealAccent, + onSecondary = ILBgDark, + secondaryContainer = ILTealContainerDark, + onSecondaryContainer = ILTextDark, + tertiary = ILAmber, + onTertiary = ILBgDark, + tertiaryContainer = ILAmberContainerDark, + onTertiaryContainer = ILOnAmberContainerDark, + background = ILBgDark, + onBackground = ILTextDark, + surface = ILSurfaceDark, + onSurface = ILTextDark, + surfaceVariant = ILSurface2Dark, + onSurfaceVariant = ILTextMutedDark, + surfaceContainer = ILSurface2Dark, + outline = ILBorderDark, + outlineVariant = ILBorderDark, + error = ILError, + onError = ILBgDark, ) -/** App-wide Material 3 theme carrying the InterlinedList brand colours and type. */ +/** + * App-wide Material 3 theme carrying the InterlinedList "Strata" brand colours and + * type. [darkTheme] is resolved by the caller from the user's appearance setting + * (System / Light / Dark); it defaults to following the OS. + */ @Composable fun InterlinedListTheme( darkTheme: Boolean = isSystemInDarkTheme(), diff --git a/core/designsystem/src/main/res/drawable-nodpi/il_mark_dark.png b/core/designsystem/src/main/res/drawable-nodpi/il_mark_dark.png deleted file mode 100644 index b8ade2d..0000000 Binary files a/core/designsystem/src/main/res/drawable-nodpi/il_mark_dark.png and /dev/null differ diff --git a/core/designsystem/src/main/res/drawable-nodpi/il_mark_light.png b/core/designsystem/src/main/res/drawable-nodpi/il_mark_light.png deleted file mode 100644 index 01ac3a5..0000000 Binary files a/core/designsystem/src/main/res/drawable-nodpi/il_mark_light.png and /dev/null differ diff --git a/core/designsystem/src/main/res/drawable/il_logo_mark.xml b/core/designsystem/src/main/res/drawable/il_logo_mark.xml new file mode 100644 index 0000000..6c610cd --- /dev/null +++ b/core/designsystem/src/main/res/drawable/il_logo_mark.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/core/designsystem/src/main/res/drawable/il_logo_mark_dark.xml b/core/designsystem/src/main/res/drawable/il_logo_mark_dark.xml new file mode 100644 index 0000000..5fd68da --- /dev/null +++ b/core/designsystem/src/main/res/drawable/il_logo_mark_dark.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/core/network/src/main/kotlin/com/interlinedlist/android/core/network/di/NetworkModule.kt b/core/network/src/main/kotlin/com/interlinedlist/android/core/network/di/NetworkModule.kt index 83265f5..be00cb5 100644 --- a/core/network/src/main/kotlin/com/interlinedlist/android/core/network/di/NetworkModule.kt +++ b/core/network/src/main/kotlin/com/interlinedlist/android/core/network/di/NetworkModule.kt @@ -24,6 +24,10 @@ object NetworkModule { fun provideJson(): Json = Json { ignoreUnknownKeys = true explicitNulls = false + // The API sends explicit `null` for many optional fields (e.g. imageUrls, + // videoUrls on a freshly created message). Coerce those to the property's + // default instead of failing to deserialize a non-nullable type. + coerceInputValues = true } @Provides diff --git a/feature/messages/src/androidTest/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedScreenTest.kt b/feature/messages/src/androidTest/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedScreenTest.kt index 7edf96a..f61c7cf 100644 --- a/feature/messages/src/androidTest/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedScreenTest.kt +++ b/feature/messages/src/androidTest/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedScreenTest.kt @@ -4,15 +4,19 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsSelected import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.test.ext.junit.runners.AndroidJUnit4 import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.messages.domain.LinkedNetwork import com.interlinedlist.android.feature.messages.domain.Message +import com.interlinedlist.android.feature.messages.ui.components.EditMessageSheetTags import com.interlinedlist.android.feature.messages.ui.components.MessageCardTags import com.interlinedlist.android.feature.messages.ui.components.MessageMediaTags +import com.interlinedlist.android.feature.messages.ui.components.ModerationDialogTags import com.interlinedlist.android.feature.messages.ui.components.ReportDialogTags import org.junit.Rule import org.junit.Test @@ -28,11 +32,13 @@ class MessagesFeedScreenTest { id: String, body: String, imageUrls: List = emptyList(), + mine: Boolean = false, + editedAt: String? = null, ) = Message( id = id, content = body, authorId = "u1", authorUsername = "adron", authorDisplayName = "Adron", authorAvatarUrl = null, createdAt = null, - digCount = 0, replyCount = 0, dugByMe = false, parentId = null, mine = false, - imageUrls = imageUrls, + digCount = 0, replyCount = 0, dugByMe = false, parentId = null, mine = mine, + imageUrls = imageUrls, editedAt = editedAt, ) /** Hosts the stateless feed with a tiny in-memory state holder. */ @@ -40,6 +46,10 @@ class MessagesFeedScreenTest { initial: MessagesFeedUiState, onOpenMessage: (String) -> Unit = {}, onReport: (Message) -> Unit = {}, + onEdit: (Message) -> Unit = {}, + onBlockUser: (Message) -> Unit = {}, + onMuteUser: (Message) -> Unit = {}, + onReportUser: (Message) -> Unit = {}, ) { composeRule.setContent { var state by mutableStateOf(initial) @@ -55,7 +65,19 @@ class MessagesFeedScreenTest { onDismissCompose = { state = state.copy(isComposeOpen = false) }, onComposeTextChange = { state = state.copy(composeText = it) }, onPost = {}, + onToggleNetwork = { id -> + val selected = if (id in state.selectedNetworkIds) { + state.selectedNetworkIds - id + } else { + state.selectedNetworkIds + id + } + state = state.copy(selectedNetworkIds = selected) + }, onReport = onReport, + onEdit = onEdit, + onBlockUser = onBlockUser, + onMuteUser = onMuteUser, + onReportUser = onReportUser, ) } } @@ -125,9 +147,105 @@ class MessagesFeedScreenTest { composeRule.onNodeWithTag(ReportDialogTags.DIALOG).assertIsDisplayed() } + @Test + fun overflowMenu_offersEdit_onOwnMessage() { + var edited: String? = null + setFeed( + MessagesFeedUiState(messages = listOf(message("mine1", "my post", mine = true))), + onEdit = { edited = it.id }, + ) + composeRule.onNodeWithTag(MessageCardTags.MENU).performClick() + composeRule.onNodeWithTag(MessageCardTags.EDIT).performClick() + assert(edited == "mine1") + } + + @Test + fun overflowMenu_offersAuthorModeration_onOthersMessage() { + var blocked: String? = null + var muted: String? = null + var reportedUser: String? = null + setFeed( + MessagesFeedUiState(messages = listOf(message("77", "not mine"))), + onBlockUser = { blocked = it.id }, + onMuteUser = { muted = it.id }, + onReportUser = { reportedUser = it.id }, + ) + composeRule.onNodeWithTag(MessageCardTags.MENU).performClick() + composeRule.onNodeWithTag(MessageCardTags.BLOCK_USER).assertIsDisplayed() + composeRule.onNodeWithTag(MessageCardTags.MUTE_USER).assertIsDisplayed() + composeRule.onNodeWithTag(MessageCardTags.REPORT_USER).performClick() + assert(reportedUser == "77") + } + + @Test + fun editSheet_isShown_whenEditTargetIsSet() { + setFeed( + MessagesFeedUiState( + editTarget = message("mine1", "my post", mine = true), + editText = "my post", + ), + ) + composeRule.onNodeWithTag(EditMessageSheetTags.INPUT).assertIsDisplayed() + } + + @Test + fun moderationDialog_isShown_whenModerationTargetIsSet() { + setFeed( + MessagesFeedUiState( + moderationTarget = com.interlinedlist.android.feature.messages.ui.feed.ModerationTarget( + message = message("77", "not mine"), + action = com.interlinedlist.android.feature.messages.ui.feed.ModerationAction.BLOCK, + ), + ), + ) + composeRule.onNodeWithTag(ModerationDialogTags.DIALOG).assertIsDisplayed() + } + + @Test + fun editedMarker_isShown_forEditedMessage() { + setFeed( + MessagesFeedUiState( + messages = listOf(message("1", "edited body", editedAt = "2026-07-31T12:00:00Z")), + ), + ) + composeRule.onNodeWithTag(MessageCardTags.EDITED).assertIsDisplayed() + } + @Test fun scheduledAction_isPresent_inTheTopBar() { setFeed(MessagesFeedUiState(messages = listOf(message("1", "hi")))) composeRule.onNodeWithTag(MessagesFeedTags.SCHEDULED_ACTION).assertIsDisplayed() } + + @Test + fun destinationsRow_rendersInterlinedListAndLinkedNetwork_andTogglesIt() { + val linkedIn = LinkedNetwork(id = "l1", provider = "linkedin", providerUsername = "Adron Hall") + setFeed( + MessagesFeedUiState( + isComposeOpen = true, + composeText = "cross-post me", + linkedNetworks = listOf(linkedIn), + ), + ) + + // InterlinedList is always present; the linked network chip is offered too. + composeRule.onNodeWithTag(MessagesFeedTags.DESTINATION_IL).assertIsDisplayed() + composeRule.onNodeWithTag(MessagesFeedTags.destinationTag("l1")).assertIsDisplayed() + + // Tapping the LinkedIn chip selects it as a cross-post target. + composeRule.onNodeWithTag(MessagesFeedTags.destinationTag("l1")).performClick() + composeRule.onNodeWithTag(MessagesFeedTags.destinationTag("l1")).assertIsSelected() + } + + @Test + fun destinationsHint_isShown_whenNoNetworksAreLinked() { + setFeed( + MessagesFeedUiState( + isComposeOpen = true, + composeText = "hi", + linkedNetworks = emptyList(), + ), + ) + composeRule.onNodeWithTag(MessagesFeedTags.DESTINATIONS_HINT).assertIsDisplayed() + } } diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/DefaultMessagesRepository.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/DefaultMessagesRepository.kt index a08ef26..b1bf7bc 100644 --- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/DefaultMessagesRepository.kt +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/DefaultMessagesRepository.kt @@ -8,10 +8,15 @@ import com.interlinedlist.android.feature.messages.data.local.toDomain import com.interlinedlist.android.feature.messages.data.local.toEntity import com.interlinedlist.android.feature.messages.data.remote.MessagesApi import com.interlinedlist.android.feature.messages.data.remote.dto.CreateMessageRequest +import com.interlinedlist.android.feature.messages.data.remote.dto.EditMessageRequest import com.interlinedlist.android.feature.messages.data.remote.dto.PaginationDto import com.interlinedlist.android.feature.messages.data.remote.dto.ReportRequest +import com.interlinedlist.android.feature.messages.data.remote.dto.UserReportRequest import com.interlinedlist.android.feature.messages.data.remote.dto.toDomain import com.interlinedlist.android.core.network.error.safeApiCall +import com.interlinedlist.android.feature.messages.domain.CreatedMessage +import com.interlinedlist.android.feature.messages.domain.CrossPostSelection +import com.interlinedlist.android.feature.messages.domain.LinkedNetwork import com.interlinedlist.android.feature.messages.domain.Message import com.interlinedlist.android.feature.messages.domain.ReportReason import kotlinx.coroutines.flow.Flow @@ -81,16 +86,23 @@ class DefaultMessagesRepository @Inject constructor( imageUrls: List, videoUrls: List, scheduledAt: String?, - ): ApiResult = withContext(dispatchers.io) { + crossPost: CrossPostSelection, + ): ApiResult = withContext(dispatchers.io) { val request = CreateMessageRequest( content = content, imageUrls = imageUrls.ifEmpty { null }, videoUrls = videoUrls.ifEmpty { null }, scheduledAt = scheduledAt, + // Encode cross-post targets per the create schema. explicitNulls=false + // drops these when empty/false, so a plain post keeps its original body. + mastodonProviderIds = crossPost.mastodonProviderIds.ifEmpty { null }, + crossPostToBluesky = crossPost.bluesky.takeIf { it }, + crossPostToLinkedIn = crossPost.linkedIn.takeIf { it }, + crossPostToTwitter = crossPost.twitter.takeIf { it }, ) when (val result = safeCall { api.createMessage(request) }) { is ApiResult.Success -> { - val message = result.data.message.toDomain(currentUserId()) + val message = result.data.data.toDomain(currentUserId()) if (message.scheduledAt != null) { // Scheduled messages are cached in the scheduled view, not the feed. messageDao.upsert(message.toEntity(feedOrder = 0L)) @@ -99,12 +111,20 @@ class DefaultMessagesRepository @Inject constructor( val topOrder = (messageDao.maxFeedOrder() ?: 0L) messageDao.upsert(message.toEntity(feedOrder = topOrder - 1L)) } - ApiResult.Success(message) + val crossPosts = result.data.crossPosts.mapNotNull { it.toDomainOrNull() } + ApiResult.Success(CreatedMessage(message = message, crossPosts = crossPosts)) } is ApiResult.Failure -> result } } + override suspend fun getLinkedNetworks(): ApiResult> = withContext(dispatchers.io) { + when (val result = safeCall { api.getIdentities() }) { + is ApiResult.Success -> ApiResult.Success(result.data.identities.map { it.toDomain() }) + is ApiResult.Failure -> result + } + } + override suspend fun uploadImage( bytes: ByteArray, fileName: String, @@ -153,7 +173,7 @@ class DefaultMessagesRepository @Inject constructor( api.createMessage(CreateMessageRequest(content = content, parentId = parentId)) }) { is ApiResult.Success -> { - val reply = result.data.message.toDomain(currentUserId()).copy(parentId = parentId) + val reply = result.data.data.toDomain(currentUserId()).copy(parentId = parentId) val base = (messageDao.maxFeedOrder() ?: 0L) + 1L messageDao.upsert(reply.toEntity(feedOrder = base)) // Reflect the new reply count on the parent if it is cached. @@ -195,6 +215,33 @@ class DefaultMessagesRepository @Inject constructor( } } + override suspend fun editMessage(messageId: String, content: String): ApiResult = + withContext(dispatchers.io) { + // Optimistically apply the new content + an "edited" marker so the feed + // and detail react immediately; roll back the whole row on failure. + val previous = currentEntity(messageId) + val editedAt = nowIso() + if (previous != null) { + messageDao.upsert(previous.copy(content = content, editedAt = editedAt)) + } + when (val result = safeCall { api.editMessage(messageId, EditMessageRequest(content = content)) }) { + is ApiResult.Success -> { + val updated = (previous?.copy(content = content, editedAt = editedAt))?.toDomain() + ?: Message( + id = messageId, content = content, authorId = "", authorUsername = "", + authorDisplayName = null, authorAvatarUrl = null, createdAt = null, + digCount = 0, replyCount = 0, dugByMe = false, parentId = null, + mine = true, editedAt = editedAt, + ) + ApiResult.Success(updated) + } + is ApiResult.Failure -> { + if (previous != null) messageDao.upsert(previous) + result + } + } + } + override suspend fun refreshScheduled(): ApiResult = withContext(dispatchers.io) { when (val result = safeCall { api.getScheduled() }) { is ApiResult.Success -> { @@ -232,6 +279,44 @@ class DefaultMessagesRepository @Inject constructor( } } + override suspend fun blockUser(username: String): ApiResult = withContext(dispatchers.io) { + when (val result = safeCall { api.blockUser(username) }) { + is ApiResult.Success -> { + // Hide the blocked author's messages from the local cache. + messageDao.deleteByAuthorUsername(username) + ApiResult.Success(Unit) + } + is ApiResult.Failure -> result + } + } + + override suspend fun muteUser(username: String): ApiResult = withContext(dispatchers.io) { + when (val result = safeCall { api.muteUser(username) }) { + is ApiResult.Success -> { + // Hide the muted author's messages from the local cache. + messageDao.deleteByAuthorUsername(username) + ApiResult.Success(Unit) + } + is ApiResult.Failure -> result + } + } + + override suspend fun reportUser( + username: String, + reason: ReportReason, + detail: String?, + ): ApiResult = withContext(dispatchers.io) { + safeCall { + api.reportUser( + username = username, + body = UserReportRequest( + reason = reason.wireValue, + detail = detail?.takeIf { it.isNotBlank() }, + ), + ) + } + } + override suspend fun fetchMetadata(messageId: String): ApiResult = withContext(dispatchers.io) { when (val result = safeCall { api.fetchMetadata(messageId) }) { is ApiResult.Success -> { @@ -284,6 +369,9 @@ class DefaultMessagesRepository @Inject constructor( private fun currentUserId(): String? = sessionStore.userId + /** Current instant as an ISO-8601 string, for the optimistic "edited" marker. */ + private fun nowIso(): String = java.time.Instant.now().toString() + /** Shared multipart upload path; extracts the hosted URL from the response. */ private suspend fun upload( bytes: ByteArray, diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/MessagesRepository.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/MessagesRepository.kt index c5986f0..5d0cf76 100644 --- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/MessagesRepository.kt +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/MessagesRepository.kt @@ -1,6 +1,9 @@ package com.interlinedlist.android.feature.messages.data import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.messages.domain.CreatedMessage +import com.interlinedlist.android.feature.messages.domain.CrossPostSelection +import com.interlinedlist.android.feature.messages.domain.LinkedNetwork import com.interlinedlist.android.feature.messages.domain.Message import com.interlinedlist.android.feature.messages.domain.ReportReason import kotlinx.coroutines.flow.Flow @@ -38,15 +41,26 @@ interface MessagesRepository { /** * Creates a new top-level message and caches it. Optionally attaches already - * uploaded [imageUrls] / [videoUrls] and defers publishing to [scheduledAt] - * (ISO-8601). A scheduled message does not enter the feed cache. + * uploaded [imageUrls] / [videoUrls], defers publishing to [scheduledAt] + * (ISO-8601), and cross-posts to the already-linked networks named by + * [crossPost] (InterlinedList-only when [CrossPostSelection.NONE]). A scheduled + * message does not enter the feed cache. Returns the created message plus any + * per-network cross-post delivery statuses the endpoint reported. */ suspend fun createMessage( content: String, imageUrls: List = emptyList(), videoUrls: List = emptyList(), scheduledAt: String? = null, - ): ApiResult + crossPost: CrossPostSelection = CrossPostSelection.NONE, + ): ApiResult + + /** + * The caller's already-linked social networks, offered as cross-post + * destinations in the composer. Read directly from the API (not cached); an + * empty list means nothing is linked yet. + */ + suspend fun getLinkedNetworks(): ApiResult> /** Uploads image [bytes] and returns the hosted URL to attach on compose. */ suspend fun uploadImage(bytes: ByteArray, fileName: String, mimeType: String): ApiResult @@ -69,6 +83,13 @@ interface MessagesRepository { /** Deletes one of the caller's own messages, removing it from the cache. */ suspend fun deleteMessage(messageId: String): ApiResult + /** + * Edits the [content] of one of the caller's own messages. Optimistically + * updates the cached message (content + an "edited" marker) and rolls the + * change back on failure. Returns the updated [Message]. + */ + suspend fun editMessage(messageId: String, content: String): ApiResult + /** Refreshes the caller's scheduled messages from the API into the cache. */ suspend fun refreshScheduled(): ApiResult @@ -78,6 +99,21 @@ interface MessagesRepository { /** Reports a message with a [reason] and optional free-text [detail]. */ suspend fun report(messageId: String, reason: ReportReason, detail: String? = null): ApiResult + /** + * Blocks the user [username]. On success, removes that author's messages from + * the local feed/reply cache so the caller stops seeing them immediately. + */ + suspend fun blockUser(username: String): ApiResult + + /** + * Mutes the user [username]. On success, removes that author's messages from + * the local feed/reply cache so the caller stops seeing them immediately. + */ + suspend fun muteUser(username: String): ApiResult + + /** Reports the user [username] with a [reason] and optional free-text [detail]. */ + suspend fun reportUser(username: String, reason: ReportReason, detail: String? = null): ApiResult + /** * Fetches link-preview metadata for [messageId]'s links and updates the cached * message so the feed/detail can render a preview card. diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessageDao.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessageDao.kt index 943547f..a19a63a 100644 --- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessageDao.kt +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessageDao.kt @@ -39,6 +39,13 @@ interface MessageDao { @Query("DELETE FROM message WHERE id = :id") suspend fun deleteById(id: String) + /** + * Removes every cached message authored by [username] (used to hide a blocked + * or muted author's messages from the local feed/replies immediately). + */ + @Query("DELETE FROM message WHERE authorUsername = :username") + suspend fun deleteByAuthorUsername(username: String) + /** Clears the top-level feed (used before writing a fresh refresh page). */ @Query("DELETE FROM message WHERE parentId IS NULL AND scheduledAt IS NULL") suspend fun clearFeed() diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessageEntity.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessageEntity.kt index 3d81d1c..21a9d04 100644 --- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessageEntity.kt +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessageEntity.kt @@ -34,6 +34,8 @@ data class MessageEntity( val linkPreview: LinkPreview? = null, /** Future send time for a scheduled message; null for a normal message. */ val scheduledAt: String? = null, + /** Last-edited instant; null when the message has not been edited. */ + val editedAt: String? = null, ) fun MessageEntity.toDomain(): Message = Message( @@ -53,6 +55,7 @@ fun MessageEntity.toDomain(): Message = Message( videoUrls = videoUrls, linkPreview = linkPreview, scheduledAt = scheduledAt, + editedAt = editedAt, ) fun Message.toEntity(feedOrder: Long): MessageEntity = MessageEntity( @@ -73,4 +76,5 @@ fun Message.toEntity(feedOrder: Long): MessageEntity = MessageEntity( videoUrls = videoUrls, linkPreview = linkPreview, scheduledAt = scheduledAt, + editedAt = editedAt, ) diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/MessagesApi.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/MessagesApi.kt index 106bd1a..fe84d22 100644 --- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/MessagesApi.kt +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/MessagesApi.kt @@ -1,17 +1,22 @@ package com.interlinedlist.android.feature.messages.data.remote import com.interlinedlist.android.feature.messages.data.remote.dto.CreateMessageRequest +import com.interlinedlist.android.feature.messages.data.remote.dto.CreateMessageResponse +import com.interlinedlist.android.feature.messages.data.remote.dto.EditMessageRequest +import com.interlinedlist.android.feature.messages.data.remote.dto.IdentitiesResponse import com.interlinedlist.android.feature.messages.data.remote.dto.MediaUploadResponse import com.interlinedlist.android.feature.messages.data.remote.dto.MessageResponse import com.interlinedlist.android.feature.messages.data.remote.dto.MessagesResponse import com.interlinedlist.android.feature.messages.data.remote.dto.MetadataResponse import com.interlinedlist.android.feature.messages.data.remote.dto.ReportRequest import com.interlinedlist.android.feature.messages.data.remote.dto.ScheduledMessagesResponse +import com.interlinedlist.android.feature.messages.data.remote.dto.UserReportRequest import okhttp3.MultipartBody import retrofit2.http.Body import retrofit2.http.DELETE import retrofit2.http.GET import retrofit2.http.Multipart +import retrofit2.http.PATCH import retrofit2.http.POST import retrofit2.http.Part import retrofit2.http.Path @@ -31,9 +36,10 @@ interface MessagesApi { @Query("offset") offset: Int, ): MessagesResponse - /** Creates a new message (or a reply when `parentId` is set). */ + /** Creates a new message (or a reply when `parentId` is set). The created + * message is returned under `data` (see [CreateMessageResponse]). */ @POST("api/messages") - suspend fun createMessage(@Body body: CreateMessageRequest): MessageResponse + suspend fun createMessage(@Body body: CreateMessageRequest): CreateMessageResponse /** A single message by id (for the detail screen). */ @GET("api/messages/{id}") @@ -55,6 +61,14 @@ interface MessagesApi { @DELETE("api/messages/{id}") suspend fun deleteMessage(@Path("id") id: String) + /** + * Edits the content of one of the caller's own messages. The endpoint's + * response body is not modelled in the OpenAPI spec; the repository updates + * the cache optimistically and treats a 2xx as success, so this returns Unit. + */ + @PATCH("api/messages/{id}") + suspend fun editMessage(@Path("id") id: String, @Body body: EditMessageRequest) + /** Full-text search over top-level messages. */ @GET("api/messages/search") suspend fun search( @@ -77,6 +91,14 @@ interface MessagesApi { @GET("api/messages/scheduled") suspend fun getScheduled(): ScheduledMessagesResponse + /** + * The caller's already-linked social identities (Mastodon/LinkedIn/X/Bluesky), + * used to offer cross-post destinations in the composer. Linking new accounts + * is a web-only OAuth flow and is not exposed here. + */ + @GET("api/user/identities") + suspend fun getIdentities(): IdentitiesResponse + /** Reports a message with a reason (and optional free-text detail). */ @POST("api/messages/{id}/report") suspend fun report(@Path("id") id: String, @Body body: ReportRequest) @@ -84,4 +106,18 @@ interface MessagesApi { /** Fetches and attaches link-preview metadata for a message's links. */ @POST("api/messages/{id}/metadata") suspend fun fetchMetadata(@Path("id") id: String): MetadataResponse + + // --- author moderation (on messages by other users) -------------------- + + /** Blocks a user by username. */ + @POST("api/users/{username}/block") + suspend fun blockUser(@Path("username") username: String) + + /** Mutes a user by username. */ + @POST("api/users/{username}/mute") + suspend fun muteUser(@Path("username") username: String) + + /** Reports a user with a reason (and optional free-text detail). */ + @POST("api/users/{username}/report") + suspend fun reportUser(@Path("username") username: String, @Body body: UserReportRequest) } diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/IdentityDto.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/IdentityDto.kt new file mode 100644 index 0000000..3a7900d --- /dev/null +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/IdentityDto.kt @@ -0,0 +1,39 @@ +package com.interlinedlist.android.feature.messages.data.remote.dto + +import com.interlinedlist.android.feature.messages.domain.LinkedNetwork +import kotlinx.serialization.Serializable + +/** + * Response from `GET /api/user/identities`: + * `{ "identities": [ { id, provider, providerUsername, profileUrl, avatarUrl, + * connectedAt, lastVerifiedAt } ] }`. + * + * The endpoint isn't schema-modelled in the OpenAPI spec, so this mirrors the + * verified live shape; the shared Json `ignoreUnknownKeys`, so extra fields (e.g. + * `lastVerifiedAt`) are tolerated. + */ +@Serializable +data class IdentitiesResponse( + val identities: List = emptyList(), +) + +/** A single linked social identity. */ +@Serializable +data class IdentityDto( + val id: String, + val provider: String, + val providerUsername: String = "", + val profileUrl: String? = null, + val avatarUrl: String? = null, + val connectedAt: String? = null, +) + +/** Maps a wire identity into the domain [LinkedNetwork]. */ +fun IdentityDto.toDomain(): LinkedNetwork = LinkedNetwork( + id = id, + provider = provider, + providerUsername = providerUsername, + profileUrl = profileUrl, + avatarUrl = avatarUrl, + connectedAt = connectedAt, +) diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessageDto.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessageDto.kt index 1ea8366..bbaae53 100644 --- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessageDto.kt +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessageDto.kt @@ -17,7 +17,12 @@ data class MessageDto( val id: String, val content: String = "", val author: MessageAuthorDto? = null, + /** The create/detail endpoints key the author sub-object as `user` rather than + * `author`; [toDomain] falls back to whichever the payload used. */ + val user: MessageAuthorDto? = null, val createdAt: String? = null, + /** Last-modified instant; when it differs from [createdAt] the message was edited. */ + val updatedAt: String? = null, val digCount: Int = 0, val replyCount: Int = 0, val dugByCurrentUser: Boolean = false, @@ -59,24 +64,31 @@ data class LinkMetadataDto( * Maps the wire model into the domain [Message]. [currentUserId] lets us flag * the caller's own messages (for delete) even when the API omits `isOwn`. */ -fun MessageDto.toDomain(currentUserId: String?): Message = Message( - id = id, - content = content, - authorId = author?.id.orEmpty(), - authorUsername = author?.username.orEmpty(), - authorDisplayName = author?.displayName, - authorAvatarUrl = author?.avatar, - createdAt = createdAt, - digCount = digCount, - replyCount = replyCount, - dugByMe = dugByCurrentUser, - parentId = parentId, - mine = isOwn || (currentUserId != null && author?.id == currentUserId), - imageUrls = imageUrls, - videoUrls = videoUrls, - linkPreview = linkMetadata?.toDomain(), - scheduledAt = scheduledAt, -) +fun MessageDto.toDomain(currentUserId: String?): Message { + // The API is inconsistent about the author key: the feed uses `author`, the + // create/detail endpoints use `user`. Prefer whichever the payload populated. + val person = author ?: user + return Message( + id = id, + content = content, + authorId = person?.id.orEmpty(), + authorUsername = person?.username.orEmpty(), + authorDisplayName = person?.displayName, + authorAvatarUrl = person?.avatar, + createdAt = createdAt, + // Treat the message as edited only when it was modified after creation. + editedAt = updatedAt?.takeIf { createdAt == null || it != createdAt }, + digCount = digCount, + replyCount = replyCount, + dugByMe = dugByCurrentUser, + parentId = parentId, + mine = isOwn || (currentUserId != null && person?.id == currentUserId), + imageUrls = imageUrls, + videoUrls = videoUrls, + linkPreview = linkMetadata?.toDomain(), + scheduledAt = scheduledAt, + ) +} /** Maps link-preview metadata into the domain, dropping empty previews. */ fun LinkMetadataDto.toDomain(): LinkPreview? { diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessagesResponse.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessagesResponse.kt index 918b392..ab22db9 100644 --- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessagesResponse.kt +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessagesResponse.kt @@ -1,5 +1,6 @@ package com.interlinedlist.android.feature.messages.data.remote.dto +import com.interlinedlist.android.feature.messages.domain.CrossPostStatus import kotlinx.serialization.Serializable /** @@ -31,13 +32,60 @@ data class MessageResponse( val message: MessageDto, ) +/** + * Response from creating a message or posting a reply. Unlike [MessageResponse], + * here `message` is a human-readable status string ("Message created + * successfully") and the created message is under [data]: + * `{ message: "…", data: { …message… }, crossPosts: [...] }`. + * + * [crossPosts] carries per-network delivery status when the post targeted linked + * networks. Its shape is not modelled in the OpenAPI spec, so it is best-effort + * and defaults to empty (the shared Json `coerceInputValues`, so an explicit + * `null` also becomes the empty default). + */ +@Serializable +data class CreateMessageResponse( + val data: MessageDto, + val crossPosts: List = emptyList(), +) + +/** + * Per-network delivery status entry in a create response's `crossPosts` array. + * Not schema-modelled, so every field is optional; the repository maps only + * entries that name a [provider]. + */ +@Serializable +data class CrossPostStatusDto( + val provider: String? = null, + val status: String? = null, + val url: String? = null, + val error: String? = null, +) { + /** Maps into the domain, or null when the entry names no provider. */ + fun toDomainOrNull(): CrossPostStatus? { + val networkProvider = provider?.takeIf { it.isNotBlank() } ?: return null + return CrossPostStatus( + provider = networkProvider, + status = status?.takeIf { it.isNotBlank() } ?: if (error != null) "failed" else "pending", + url = url, + error = error, + ) + } +} + /** * Request body for creating a message or posting a reply. * * [imageUrls] / [videoUrls] carry media previously uploaded via the upload * endpoints, and [scheduledAt] (ISO-8601) defers publishing to a future time. + * + * Cross-posting targets are encoded per the create schema: [mastodonProviderIds] + * lists the selected mastodon identity ids (a user may link several instances), + * while [crossPostToBluesky] / [crossPostToLinkedIn] / [crossPostToTwitter] are + * single boolean flags for the one-account networks. + * * Only non-null fields are serialised (the shared Json uses `explicitNulls = - * false`), so a plain post still sends just `{ content }`. + * false`), so a plain InterlinedList-only post still sends just `{ content }`. */ @Serializable data class CreateMessageRequest( @@ -46,6 +94,10 @@ data class CreateMessageRequest( val imageUrls: List? = null, val videoUrls: List? = null, val scheduledAt: String? = null, + val mastodonProviderIds: List? = null, + val crossPostToBluesky: Boolean? = null, + val crossPostToLinkedIn: Boolean? = null, + val crossPostToTwitter: Boolean? = null, ) /** @@ -79,6 +131,26 @@ data class ReportRequest( val detail: String? = null, ) +/** + * Request body for editing one of the caller's own messages via + * `PATCH /api/messages/{id}`. Only `content` is sent; the shared Json uses + * `explicitNulls = false`, so the body is a plain `{ "content": "…" }`. + */ +@Serializable +data class EditMessageRequest( + val content: String, +) + +/** + * Request body for reporting a *user* via `POST /api/users/{username}/report`. + * Mirrors the message [ReportRequest] shape: `{ reason, detail? }`. + */ +@Serializable +data class UserReportRequest( + val reason: String, + val detail: String? = null, +) + /** * Response from the metadata endpoint. The updated message (with its populated * `linkMetadata`) is returned either at the top level or under `message`. diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/domain/CrossPost.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/domain/CrossPost.kt new file mode 100644 index 0000000..e74f43e --- /dev/null +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/domain/CrossPost.kt @@ -0,0 +1,133 @@ +package com.interlinedlist.android.feature.messages.domain + +/** + * A social network the current user has already linked on the web, returned by + * `GET /api/user/identities`. These are the candidate cross-post destinations the + * composer offers; linking new accounts (OAuth) happens on the web and is out of + * scope for the app. + * + * [provider] is the raw wire value (e.g. `mastodon:techhub.social`, `linkedin`); + * [networkProvider] normalises it to a known [NetworkProvider] for icon/label and, + * critically, to decide how the selection is encoded on the create request (a + * per-instance mastodon id vs. a single boolean flag for the others). + */ +data class LinkedNetwork( + /** The identity's stable id (used as a `mastodonProviderIds` entry). */ + val id: String, + /** Raw provider string from the API, e.g. `mastodon:techhub.social`. */ + val provider: String, + /** The handle/display name on that network, for the chip label. */ + val providerUsername: String, + val profileUrl: String? = null, + val avatarUrl: String? = null, + val connectedAt: String? = null, +) { + /** The normalised provider family, derived from [provider]'s prefix. */ + val networkProvider: NetworkProvider get() = NetworkProvider.fromWire(provider) + + /** + * A short label for the destination chip. Mastodon shows its instance host + * (e.g. `techhub.social`) since a user may link more than one; the others + * show the single network's display name. + */ + val chipLabel: String + get() = when (networkProvider) { + NetworkProvider.MASTODON -> provider.substringAfter(':', missingDelimiterValue = "Mastodon") + else -> networkProvider.displayName + } +} + +/** The cross-post networks the create endpoint understands. */ +enum class NetworkProvider(val displayName: String) { + MASTODON("Mastodon"), + LINKEDIN("LinkedIn"), + TWITTER("X"), + BLUESKY("Bluesky"), + /** Any linked provider the app doesn't yet know how to cross-post to. */ + OTHER("Other"); + + companion object { + /** + * Maps a raw `provider` value to a [NetworkProvider]. Mastodon arrives as + * `mastodon:` (per-instance), so match on the prefix; the rest are + * plain tokens. + */ + fun fromWire(provider: String): NetworkProvider = when { + provider.startsWith("mastodon", ignoreCase = true) -> MASTODON + provider.equals("linkedin", ignoreCase = true) -> LINKEDIN + provider.equals("twitter", ignoreCase = true) || + provider.equals("x", ignoreCase = true) -> TWITTER + provider.equals("bluesky", ignoreCase = true) -> BLUESKY + else -> OTHER + } + } +} + +/** + * The set of already-linked networks the composer has selected as cross-post + * targets for a post. Built from the toggled [LinkedNetwork]s; the repository + * translates it into the discrete create-request fields (a mastodon id list plus + * the per-network boolean flags). An empty selection means InterlinedList-only. + */ +data class CrossPostSelection( + /** Selected mastodon identity ids (a user may link several instances). */ + val mastodonProviderIds: List = emptyList(), + val bluesky: Boolean = false, + val linkedIn: Boolean = false, + val twitter: Boolean = false, +) { + /** True when at least one external network is targeted. */ + val hasTargets: Boolean + get() = mastodonProviderIds.isNotEmpty() || bluesky || linkedIn || twitter + + companion object { + val NONE = CrossPostSelection() + + /** Folds a set of selected [networks] into a [CrossPostSelection]. */ + fun from(networks: Collection): CrossPostSelection = CrossPostSelection( + mastodonProviderIds = networks + .filter { it.networkProvider == NetworkProvider.MASTODON } + .map { it.id }, + bluesky = networks.any { it.networkProvider == NetworkProvider.BLUESKY }, + linkedIn = networks.any { it.networkProvider == NetworkProvider.LINKEDIN }, + twitter = networks.any { it.networkProvider == NetworkProvider.TWITTER }, + ) + } +} + +/** + * Per-network delivery status for a cross-posted message, parsed from the create + * response's `crossPosts` array. The response shape is not modelled in the OpenAPI + * spec, so all fields are best-effort: [provider] identifies the target, [status] + * is a free-text state (e.g. `success`, `pending`, `failed`), and [url]/[error] + * are surfaced when present. + */ +data class CrossPostStatus( + val provider: String, + val status: String, + val url: String? = null, + val error: String? = null, +) { + val networkProvider: NetworkProvider get() = NetworkProvider.fromWire(provider) + + /** A human label for the network the post was sent to. */ + val label: String + get() = when (networkProvider) { + NetworkProvider.MASTODON -> provider.substringAfter(':', missingDelimiterValue = "Mastodon") + NetworkProvider.OTHER -> provider + else -> networkProvider.displayName + } + + val isSuccess: Boolean get() = status.equals("success", ignoreCase = true) || url != null && error == null + val isFailed: Boolean get() = status.equals("failed", ignoreCase = true) || error != null +} + +/** + * The outcome of creating a message: the created [message] plus any per-network + * [crossPosts] delivery statuses the create endpoint reported. [crossPosts] is + * empty for a plain InterlinedList-only post. + */ +data class CreatedMessage( + val message: Message, + val crossPosts: List = emptyList(), +) diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/domain/Message.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/domain/Message.kt index a7348f1..dbfcc1f 100644 --- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/domain/Message.kt +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/domain/Message.kt @@ -34,12 +34,20 @@ data class Message( * normal message. Present on rows returned by the scheduled endpoint. */ val scheduledAt: String? = null, + /** + * ISO-8601 last-edited instant for a message whose content was changed after + * it was posted; null for an un-edited message. Drives the "edited" indicator. + */ + val editedAt: String? = null, ) { /** Best available display label for the author. */ val authorLabel: String get() = authorDisplayName?.takeIf { it.isNotBlank() } ?: authorUsername /** True when any image or video media is attached. */ val hasMedia: Boolean get() = imageUrls.isNotEmpty() || videoUrls.isNotEmpty() + + /** True when the message has been edited since it was posted. */ + val isEdited: Boolean get() = editedAt != null } /** diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/components/EditMessageSheet.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/components/EditMessageSheet.kt new file mode 100644 index 0000000..3a0cfe1 --- /dev/null +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/components/EditMessageSheet.kt @@ -0,0 +1,96 @@ +package com.interlinedlist.android.feature.messages.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp + +/** Stable test tags for the in-place edit sheet. */ +object EditMessageSheetTags { + const val SHEET = "editMessageSheet" + const val INPUT = "editMessageInput" + const val SAVE = "editMessageSave" +} + +/** + * In-place editor for one of the caller's own messages: a bottom sheet seeded with + * the current content. Saving PATCHes the new text; the feed/detail update + * optimistically and gain an "edited" marker. + * + * @param onSave invoked when the user confirms the edit. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EditMessageSheet( + text: String, + canSave: Boolean, + isSaving: Boolean, + onTextChange: (String) -> Unit, + onDismiss: () -> Unit, + onSave: () -> Unit, +) { + ModalBottomSheet( + onDismissRequest = onDismiss, + modifier = Modifier.testTag(EditMessageSheetTags.SHEET), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .imePadding() + .padding(horizontal = 20.dp, vertical = 12.dp), + ) { + Text( + text = "Edit message", + style = MaterialTheme.typography.titleMedium, + ) + Spacer(Modifier.height(12.dp)) + OutlinedTextField( + value = text, + onValueChange = onTextChange, + placeholder = { Text("Edit your message…") }, + enabled = !isSaving, + minLines = 3, + modifier = Modifier + .fillMaxWidth() + .testTag(EditMessageSheetTags.INPUT), + ) + Spacer(Modifier.height(12.dp)) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + TextButton(onClick = onDismiss, enabled = !isSaving) { Text("Cancel") } + Spacer(Modifier.height(8.dp)) + Button( + onClick = onSave, + enabled = canSave, + modifier = Modifier.testTag(EditMessageSheetTags.SAVE), + ) { + if (isSaving) { + CircularProgressIndicator( + modifier = Modifier.height(20.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + } else { + Text("Save") + } + } + } + Spacer(Modifier.height(12.dp)) + } + } +} diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/components/MessageCard.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/components/MessageCard.kt index 26ba171..246b6e7 100644 --- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/components/MessageCard.kt +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/components/MessageCard.kt @@ -45,8 +45,13 @@ object MessageCardTags { const val REPLY = "messageReply" const val MENU = "messageMenu" const val DELETE = "messageDelete" + const val EDIT = "messageEdit" const val REPORT = "messageReport" + const val BLOCK_USER = "messageBlockUser" + const val MUTE_USER = "messageMuteUser" + const val REPORT_USER = "messageReportUser" const val BODY = "messageBody" + const val EDITED = "messageEdited" } /** @@ -62,6 +67,10 @@ fun MessageCard( onDelete: () -> Unit, modifier: Modifier = Modifier, onReport: () -> Unit = {}, + onEdit: () -> Unit = {}, + onBlockUser: () -> Unit = {}, + onMuteUser: () -> Unit = {}, + onReportUser: () -> Unit = {}, onOpenLink: (String) -> Unit = {}, ) { Row( @@ -87,8 +96,24 @@ fun MessageCard( color = MaterialTheme.colorScheme.onSurfaceVariant, ) } + if (message.isEdited) { + Text( + text = " · edited", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.testTag(MessageCardTags.EDITED), + ) + } Spacer(Modifier.weight(1f)) - MessageMenu(isMine = message.mine, onDelete = onDelete, onReport = onReport) + MessageMenu( + isMine = message.mine, + onEdit = onEdit, + onDelete = onDelete, + onReport = onReport, + onBlockUser = onBlockUser, + onMuteUser = onMuteUser, + onReportUser = onReportUser, + ) } Spacer(Modifier.size(4.dp)) Text( @@ -125,11 +150,20 @@ fun MessageCard( } /** - * Overflow menu: own messages offer Delete; everyone else's offer Report. Renders - * nothing when there is no applicable action (defensive; both branches are covered). + * Overflow menu. Own messages offer Edit + Delete. Everyone else's offer author + * moderation — Block user, Mute user, Report user — plus the existing Report + * (message) action. */ @Composable -private fun MessageMenu(isMine: Boolean, onDelete: () -> Unit, onReport: () -> Unit) { +private fun MessageMenu( + isMine: Boolean, + onEdit: () -> Unit, + onDelete: () -> Unit, + onReport: () -> Unit, + onBlockUser: () -> Unit, + onMuteUser: () -> Unit, + onReportUser: () -> Unit, +) { var expanded by remember { mutableStateOf(false) } Box { IconButton( @@ -140,6 +174,14 @@ private fun MessageMenu(isMine: Boolean, onDelete: () -> Unit, onReport: () -> U } DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { if (isMine) { + DropdownMenuItem( + text = { Text("Edit") }, + onClick = { + expanded = false + onEdit() + }, + modifier = Modifier.testTag(MessageCardTags.EDIT), + ) DropdownMenuItem( text = { Text("Delete") }, onClick = { @@ -150,13 +192,37 @@ private fun MessageMenu(isMine: Boolean, onDelete: () -> Unit, onReport: () -> U ) } else { DropdownMenuItem( - text = { Text("Report") }, + text = { Text("Report message") }, onClick = { expanded = false onReport() }, modifier = Modifier.testTag(MessageCardTags.REPORT), ) + DropdownMenuItem( + text = { Text("Block user") }, + onClick = { + expanded = false + onBlockUser() + }, + modifier = Modifier.testTag(MessageCardTags.BLOCK_USER), + ) + DropdownMenuItem( + text = { Text("Mute user") }, + onClick = { + expanded = false + onMuteUser() + }, + modifier = Modifier.testTag(MessageCardTags.MUTE_USER), + ) + DropdownMenuItem( + text = { Text("Report user") }, + onClick = { + expanded = false + onReportUser() + }, + modifier = Modifier.testTag(MessageCardTags.REPORT_USER), + ) } } } diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/components/ModerationDialog.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/components/ModerationDialog.kt new file mode 100644 index 0000000..4e720bd --- /dev/null +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/components/ModerationDialog.kt @@ -0,0 +1,118 @@ +package com.interlinedlist.android.feature.messages.ui.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.selectable +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import com.interlinedlist.android.feature.messages.domain.ReportReason +import com.interlinedlist.android.feature.messages.ui.feed.ModerationAction +import com.interlinedlist.android.feature.messages.ui.feed.ModerationTarget + +/** Stable test tags for the author-moderation confirm dialog. */ +object ModerationDialogTags { + const val DIALOG = "moderationDialog" + const val DETAIL = "moderationDetail" + const val CONFIRM = "moderationConfirm" +} + +/** + * Confirmation dialog for an author-moderation action. Block and Mute are simple + * confirms; Report additionally collects a [ReportReason] (required) and optional + * free-text detail before it can be submitted. + * + * @param onConfirm invoked with the chosen reason (Report only) and detail. + */ +@Composable +fun ModerationDialog( + target: ModerationTarget, + isSubmitting: Boolean, + onDismiss: () -> Unit, + onConfirm: (ReportReason?, String) -> Unit, +) { + val label = target.authorLabel + val isReport = target.action == ModerationAction.REPORT + var reason by remember { mutableStateOf(null) } + var detail by remember { mutableStateOf("") } + + val (title, body, confirmLabel) = when (target.action) { + ModerationAction.BLOCK -> + Triple("Block $label?", "You won't see their messages, and they can't interact with you.", "Block") + ModerationAction.MUTE -> + Triple("Mute $label?", "Their messages will be hidden from your feed.", "Mute") + ModerationAction.REPORT -> + Triple("Report $label", "Why are you reporting this user?", "Report") + } + + AlertDialog( + onDismissRequest = onDismiss, + modifier = Modifier.testTag(ModerationDialogTags.DIALOG), + title = { Text(title) }, + text = { + Column { + Text( + text = body, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (isReport) { + Spacer(Modifier.height(8.dp)) + ReportReason.entries.forEach { option -> + Row( + modifier = Modifier + .fillMaxWidth() + .selectable( + selected = reason == option, + onClick = { reason = option }, + ) + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton(selected = reason == option, onClick = { reason = option }) + Text(option.label, style = MaterialTheme.typography.bodyMedium) + } + } + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = detail, + onValueChange = { detail = it }, + placeholder = { Text("Add details (optional)") }, + enabled = !isSubmitting, + modifier = Modifier + .fillMaxWidth() + .testTag(ModerationDialogTags.DETAIL), + ) + } + } + }, + confirmButton = { + TextButton( + onClick = { onConfirm(reason, detail) }, + enabled = !isSubmitting && (!isReport || reason != null), + modifier = Modifier.testTag(ModerationDialogTags.CONFIRM), + ) { + Text(confirmLabel) + } + }, + dismissButton = { + TextButton(onClick = onDismiss, enabled = !isSubmitting) { Text("Cancel") } + }, + ) +} diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/detail/MessageDetailScreen.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/detail/MessageDetailScreen.kt index 2f21587..3c9b6a0 100644 --- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/detail/MessageDetailScreen.kt +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/detail/MessageDetailScreen.kt @@ -38,8 +38,11 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme import com.interlinedlist.android.feature.messages.domain.Message import com.interlinedlist.android.feature.messages.domain.ReportReason +import com.interlinedlist.android.feature.messages.ui.components.EditMessageSheet import com.interlinedlist.android.feature.messages.ui.components.MessageCard +import com.interlinedlist.android.feature.messages.ui.components.ModerationDialog import com.interlinedlist.android.feature.messages.ui.components.ReportDialog +import com.interlinedlist.android.feature.messages.ui.feed.ModerationAction /** Stable test tags for the detail screen. */ object MessageDetailTags { @@ -76,9 +79,18 @@ fun MessageDetailRoute( onPostReply = viewModel::postReply, onRetry = viewModel::load, onReport = viewModel::openReport, + onEdit = viewModel::openEdit, + onBlockUser = { viewModel.openModeration(it, ModerationAction.BLOCK) }, + onMuteUser = { viewModel.openModeration(it, ModerationAction.MUTE) }, + onReportUser = { viewModel.openModeration(it, ModerationAction.REPORT) }, onFetchMetadata = { viewModel.onFetchMetadata() }, onDismissReport = viewModel::dismissReport, onSubmitReport = viewModel::submitReport, + onEditTextChange = viewModel::onEditTextChange, + onDismissEdit = viewModel::dismissEdit, + onSaveEdit = viewModel::saveEdit, + onDismissModeration = viewModel::dismissModeration, + onConfirmModeration = viewModel::confirmModeration, modifier = modifier, ) } @@ -96,9 +108,18 @@ fun MessageDetailScreen( onRetry: () -> Unit, modifier: Modifier = Modifier, onReport: (Message) -> Unit = {}, + onEdit: (Message) -> Unit = {}, + onBlockUser: (Message) -> Unit = {}, + onMuteUser: (Message) -> Unit = {}, + onReportUser: (Message) -> Unit = {}, onFetchMetadata: (Message) -> Unit = {}, onDismissReport: () -> Unit = {}, onSubmitReport: (ReportReason, String) -> Unit = { _, _ -> }, + onEditTextChange: (String) -> Unit = {}, + onDismissEdit: () -> Unit = {}, + onSaveEdit: () -> Unit = {}, + onDismissModeration: () -> Unit = {}, + onConfirmModeration: (ReportReason?, String) -> Unit = { _, _ -> }, ) { Scaffold( modifier = modifier @@ -128,6 +149,10 @@ fun MessageDetailScreen( onReplyTextChange = onReplyTextChange, onPostReply = onPostReply, onReport = onReport, + onEdit = onEdit, + onBlockUser = onBlockUser, + onMuteUser = onMuteUser, + onReportUser = onReportUser, onFetchMetadata = onFetchMetadata, ) } @@ -140,6 +165,26 @@ fun MessageDetailScreen( isSubmitting = state.isReporting, ) } + + if (state.editTarget != null) { + EditMessageSheet( + text = state.editText, + canSave = state.canSaveEdit, + isSaving = state.isSavingEdit, + onTextChange = onEditTextChange, + onDismiss = onDismissEdit, + onSave = onSaveEdit, + ) + } + + state.moderationTarget?.let { target -> + ModerationDialog( + target = target, + isSubmitting = state.isModerating, + onDismiss = onDismissModeration, + onConfirm = onConfirmModeration, + ) + } } @Composable @@ -151,6 +196,10 @@ private fun Content( onReplyTextChange: (String) -> Unit, onPostReply: () -> Unit, onReport: (Message) -> Unit, + onEdit: (Message) -> Unit, + onBlockUser: (Message) -> Unit, + onMuteUser: (Message) -> Unit, + onReportUser: (Message) -> Unit, onFetchMetadata: (Message) -> Unit, ) { val message = state.message @@ -172,6 +221,10 @@ private fun Content( onDig = onDig, onDelete = {}, onReport = { onReport(message) }, + onEdit = { onEdit(message) }, + onBlockUser = { onBlockUser(message) }, + onMuteUser = { onMuteUser(message) }, + onReportUser = { onReportUser(message) }, onOpenLink = { onFetchMetadata(message) }, ) HorizontalDivider(thickness = 2.dp, color = MaterialTheme.colorScheme.outlineVariant) @@ -189,6 +242,10 @@ private fun Content( onDig = {}, onDelete = {}, onReport = { onReport(reply) }, + onEdit = { onEdit(reply) }, + onBlockUser = { onBlockUser(reply) }, + onMuteUser = { onMuteUser(reply) }, + onReportUser = { onReportUser(reply) }, onOpenLink = { onFetchMetadata(reply) }, ) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/detail/MessageDetailViewModel.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/detail/MessageDetailViewModel.kt index 615805c..a11a22c 100644 --- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/detail/MessageDetailViewModel.kt +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/detail/MessageDetailViewModel.kt @@ -8,6 +8,8 @@ import com.interlinedlist.android.core.common.result.AppError import com.interlinedlist.android.feature.messages.data.MessagesRepository import com.interlinedlist.android.feature.messages.domain.Message import com.interlinedlist.android.feature.messages.domain.ReportReason +import com.interlinedlist.android.feature.messages.ui.feed.ModerationAction +import com.interlinedlist.android.feature.messages.ui.feed.ModerationTarget import com.interlinedlist.android.feature.messages.ui.isSubscriptionGate import com.interlinedlist.android.feature.messages.ui.toUserMessage import dagger.hilt.android.lifecycle.HiltViewModel @@ -35,8 +37,16 @@ data class MessageDetailUiState( /** The message (root or a reply) being reported, if any. */ val reportTarget: Message? = null, val isReporting: Boolean = false, + /** The message (root or a reply) being edited in-place, if any. */ + val editTarget: Message? = null, + val editText: String = "", + val isSavingEdit: Boolean = false, + /** A pending author-moderation action awaiting confirmation, if any. */ + val moderationTarget: ModerationTarget? = null, + val isModerating: Boolean = false, ) { val canReply: Boolean get() = replyText.isNotBlank() && !isPostingReply + val canSaveEdit: Boolean get() = editText.isNotBlank() && !isSavingEdit } private data class DetailTransientState( @@ -47,6 +57,11 @@ private data class DetailTransientState( val isPostingReply: Boolean = false, val reportTarget: Message? = null, val isReporting: Boolean = false, + val editTarget: Message? = null, + val editText: String = "", + val isSavingEdit: Boolean = false, + val moderationTarget: ModerationTarget? = null, + val isModerating: Boolean = false, ) @HiltViewModel @@ -77,6 +92,11 @@ class MessageDetailViewModel @Inject constructor( isPostingReply = t.isPostingReply, reportTarget = t.reportTarget, isReporting = t.isReporting, + editTarget = t.editTarget, + editText = t.editText, + isSavingEdit = t.isSavingEdit, + moderationTarget = t.moderationTarget, + isModerating = t.isModerating, ) }.stateIn( scope = viewModelScope, @@ -152,6 +172,66 @@ class MessageDetailViewModel @Inject constructor( } } + // --- edit own message -------------------------------------------------- + + fun openEdit(message: Message) = transient.update { + it.copy(editTarget = message, editText = message.content, errorMessage = null) + } + + fun onEditTextChange(value: String) = transient.update { it.copy(editText = value) } + + fun dismissEdit() = transient.update { + it.copy(editTarget = null, editText = "", isSavingEdit = false) + } + + fun saveEdit() { + val target = transient.value.editTarget ?: return + val text = transient.value.editText.trim() + if (text.isBlank()) return + transient.update { it.copy(isSavingEdit = true, errorMessage = null) } + viewModelScope.launch { + when (val result = repository.editMessage(target.id, text)) { + is ApiResult.Success -> transient.update { + it.copy(isSavingEdit = false, editTarget = null, editText = "") + } + is ApiResult.Failure -> transient.update { + it.copy(isSavingEdit = false).withError(result.error) + } + } + } + } + + // --- author moderation ------------------------------------------------- + + fun openModeration(message: Message, action: ModerationAction) = transient.update { + it.copy(moderationTarget = ModerationTarget(message, action), errorMessage = null) + } + + fun dismissModeration() = transient.update { + it.copy(moderationTarget = null, isModerating = false) + } + + fun confirmModeration(reason: ReportReason? = null, detail: String = "") { + val target = transient.value.moderationTarget ?: return + transient.update { it.copy(isModerating = true, errorMessage = null) } + viewModelScope.launch { + val result = when (target.action) { + ModerationAction.BLOCK -> repository.blockUser(target.username) + ModerationAction.MUTE -> repository.muteUser(target.username) + ModerationAction.REPORT -> + repository.reportUser(target.username, reason ?: ReportReason.OTHER, detail) + } + when (result) { + is ApiResult.Success -> transient.update { + it.copy(isModerating = false, moderationTarget = null) + } + is ApiResult.Failure -> transient.update { + it.copy(isModerating = false, moderationTarget = null).withError(result.error) + } + } + } + } + // --- link metadata ----------------------------------------------------- /** Fetches link-preview metadata for the current message; cache re-emits it. */ diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedScreen.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedScreen.kt index f5feb47..523a1c5 100644 --- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedScreen.kt +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedScreen.kt @@ -13,8 +13,11 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState @@ -24,10 +27,12 @@ import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Image import androidx.compose.material.icons.filled.Schedule import androidx.compose.material.icons.filled.Videocam +import androidx.compose.material.icons.filled.Check import androidx.compose.material3.AssistChip import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -36,6 +41,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar @@ -54,9 +60,13 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.messages.domain.CrossPostStatus +import com.interlinedlist.android.feature.messages.domain.LinkedNetwork import com.interlinedlist.android.feature.messages.domain.Message import com.interlinedlist.android.feature.messages.domain.ReportReason +import com.interlinedlist.android.feature.messages.ui.components.EditMessageSheet import com.interlinedlist.android.feature.messages.ui.components.MessageCard +import com.interlinedlist.android.feature.messages.ui.components.ModerationDialog import com.interlinedlist.android.feature.messages.ui.components.ReportDialog import com.interlinedlist.android.feature.messages.ui.readMediaBytes import java.time.Instant @@ -76,6 +86,17 @@ object MessagesFeedTags { const val COMPOSE_ADD_VIDEO = "messagesComposeAddVideo" const val COMPOSE_SCHEDULE = "messagesComposeSchedule" const val SCHEDULED_ACTION = "messagesFeedScheduledAction" + + /** The always-on InterlinedList destination chip. */ + const val DESTINATION_IL = "messagesComposeDestinationInterlinedList" + /** Prefix for a per-network destination chip; suffixed with the network id. */ + const val DESTINATION_PREFIX = "messagesComposeDestination_" + /** Hint shown when the account has no linked networks to cross-post to. */ + const val DESTINATIONS_HINT = "messagesComposeDestinationsHint" + /** Post-send banner listing per-network cross-post statuses. */ + const val CROSS_POST_STATUS = "messagesFeedCrossPostStatus" + + fun destinationTag(networkId: String): String = DESTINATION_PREFIX + networkId } /** @@ -102,6 +123,10 @@ fun MessagesRoute( onDig = viewModel::onDig, onDelete = viewModel::onDelete, onReport = viewModel::openReport, + onEdit = viewModel::openEdit, + onBlockUser = { viewModel.openModeration(it, ModerationAction.BLOCK) }, + onMuteUser = { viewModel.openModeration(it, ModerationAction.MUTE) }, + onReportUser = { viewModel.openModeration(it, ModerationAction.REPORT) }, onFetchMetadata = viewModel::onFetchMetadata, onOpenCompose = viewModel::openCompose, onDismissCompose = viewModel::dismissCompose, @@ -116,8 +141,15 @@ fun MessagesRoute( }, onRemoveAttachment = viewModel::onRemoveAttachment, onScheduleChange = viewModel::onScheduleChange, + onToggleNetwork = viewModel::onToggleNetwork, + onDismissCrossPostStatuses = viewModel::dismissCrossPostStatuses, onDismissReport = viewModel::dismissReport, onSubmitReport = viewModel::submitReport, + onEditTextChange = viewModel::onEditTextChange, + onDismissEdit = viewModel::dismissEdit, + onSaveEdit = viewModel::saveEdit, + onDismissModeration = viewModel::dismissModeration, + onConfirmModeration = viewModel::confirmModeration, modifier = modifier, ) } @@ -139,12 +171,23 @@ fun MessagesFeedScreen( modifier: Modifier = Modifier, onOpenScheduled: () -> Unit = {}, onReport: (Message) -> Unit = {}, + onEdit: (Message) -> Unit = {}, + onBlockUser: (Message) -> Unit = {}, + onMuteUser: (Message) -> Unit = {}, + onReportUser: (Message) -> Unit = {}, onFetchMetadata: (Message) -> Unit = {}, onAttachMedia: (Uri, Boolean) -> Unit = { _, _ -> }, onRemoveAttachment: (PendingAttachment) -> Unit = {}, onScheduleChange: (String?) -> Unit = {}, + onToggleNetwork: (String) -> Unit = {}, + onDismissCrossPostStatuses: () -> Unit = {}, onDismissReport: () -> Unit = {}, onSubmitReport: (ReportReason, String) -> Unit = { _, _ -> }, + onEditTextChange: (String) -> Unit = {}, + onDismissEdit: () -> Unit = {}, + onSaveEdit: () -> Unit = {}, + onDismissModeration: () -> Unit = {}, + onConfirmModeration: (ReportReason?, String) -> Unit = { _, _ -> }, ) { Scaffold( modifier = modifier.fillMaxSize(), @@ -186,6 +229,10 @@ fun MessagesFeedScreen( onDig = onDig, onDelete = onDelete, onReport = onReport, + onEdit = onEdit, + onBlockUser = onBlockUser, + onMuteUser = onMuteUser, + onReportUser = onReportUser, onFetchMetadata = onFetchMetadata, ) } @@ -200,6 +247,14 @@ fun MessagesFeedScreen( onAttachMedia = onAttachMedia, onRemoveAttachment = onRemoveAttachment, onScheduleChange = onScheduleChange, + onToggleNetwork = onToggleNetwork, + ) + } + + if (state.crossPostStatuses.isNotEmpty()) { + CrossPostStatusBanner( + statuses = state.crossPostStatuses, + onDismiss = onDismissCrossPostStatuses, ) } @@ -210,6 +265,26 @@ fun MessagesFeedScreen( isSubmitting = state.isReporting, ) } + + if (state.editTarget != null) { + EditMessageSheet( + text = state.editText, + canSave = state.canSaveEdit, + isSaving = state.isSavingEdit, + onTextChange = onEditTextChange, + onDismiss = onDismissEdit, + onSave = onSaveEdit, + ) + } + + state.moderationTarget?.let { target -> + ModerationDialog( + target = target, + isSubmitting = state.isModerating, + onDismiss = onDismissModeration, + onConfirm = onConfirmModeration, + ) + } } @OptIn(ExperimentalMaterial3Api::class) @@ -223,6 +298,10 @@ private fun FeedContent( onDig: (Message) -> Unit, onDelete: (Message) -> Unit, onReport: (Message) -> Unit, + onEdit: (Message) -> Unit, + onBlockUser: (Message) -> Unit, + onMuteUser: (Message) -> Unit, + onReportUser: (Message) -> Unit, onFetchMetadata: (Message) -> Unit, ) { PullToRefreshBox( @@ -243,6 +322,10 @@ private fun FeedContent( onDig = onDig, onDelete = onDelete, onReport = onReport, + onEdit = onEdit, + onBlockUser = onBlockUser, + onMuteUser = onMuteUser, + onReportUser = onReportUser, onFetchMetadata = onFetchMetadata, ) } @@ -257,6 +340,10 @@ private fun FeedList( onDig: (Message) -> Unit, onDelete: (Message) -> Unit, onReport: (Message) -> Unit, + onEdit: (Message) -> Unit, + onBlockUser: (Message) -> Unit, + onMuteUser: (Message) -> Unit, + onReportUser: (Message) -> Unit, onFetchMetadata: (Message) -> Unit, ) { val listState = rememberLazyListState() @@ -282,6 +369,10 @@ private fun FeedList( onDig = { onDig(message) }, onDelete = { onDelete(message) }, onReport = { onReport(message) }, + onEdit = { onEdit(message) }, + onBlockUser = { onBlockUser(message) }, + onMuteUser = { onMuteUser(message) }, + onReportUser = { onReportUser(message) }, onOpenLink = { onFetchMetadata(message) }, ) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) @@ -367,6 +458,7 @@ private fun ComposeSheet( onAttachMedia: (Uri, Boolean) -> Unit, onRemoveAttachment: (PendingAttachment) -> Unit, onScheduleChange: (String?) -> Unit, + onToggleNetwork: (String) -> Unit, ) { val imagePicker = rememberLauncherForActivityResult( ActivityResultContracts.GetContent(), @@ -426,6 +518,14 @@ private fun ComposeSheet( ) } + Spacer(Modifier.height(12.dp)) + DestinationsRow( + networks = state.linkedNetworks, + selectedIds = state.selectedNetworkIds, + enabled = !state.isPosting, + onToggleNetwork = onToggleNetwork, + ) + Spacer(Modifier.height(12.dp)) Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { TextButton(onClick = onDismiss, enabled = !state.isPosting) { Text("Cancel") } @@ -489,6 +589,137 @@ private fun AttachmentRow( } } +/** + * The cross-post destinations row: InterlinedList is always-on (rendered as a + * disabled, always-selected chip), followed by a toggle chip per already-linked + * network. When nothing is linked, a subtle hint points the user to the web to + * link accounts — the app does not build an OAuth connect flow. + */ +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +private fun DestinationsRow( + networks: List, + selectedIds: Set, + enabled: Boolean, + onToggleNetwork: (String) -> Unit, +) { + Column { + Text( + text = "Post to", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(6.dp)) + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + // InterlinedList is always a destination; shown selected and locked. + FilterChip( + selected = true, + onClick = {}, + enabled = false, + label = { Text("InterlinedList") }, + leadingIcon = { + Icon(Icons.Filled.Check, contentDescription = null, modifier = Modifier.size(16.dp)) + }, + modifier = Modifier.testTag(MessagesFeedTags.DESTINATION_IL), + ) + networks.forEach { network -> + val isSelected = network.id in selectedIds + FilterChip( + selected = isSelected, + onClick = { onToggleNetwork(network.id) }, + enabled = enabled, + label = { Text(network.chipLabel) }, + leadingIcon = if (isSelected) { + { + Icon( + Icons.Filled.Check, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + } + } else { + null + }, + modifier = Modifier.testTag(MessagesFeedTags.destinationTag(network.id)), + ) + } + } + if (networks.isEmpty()) { + Spacer(Modifier.height(4.dp)) + Text( + text = "Link accounts on the web to cross-post.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.testTag(MessagesFeedTags.DESTINATIONS_HINT), + ) + } + } +} + +/** + * A brief, dismissible banner surfacing the per-network cross-post statuses + * returned by the create endpoint. Anchored to the bottom of the screen. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun CrossPostStatusBanner( + statuses: List, + onDismiss: () -> Unit, +) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.BottomCenter) { + Surface( + tonalElevation = 3.dp, + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(16.dp) + .testTag(MessagesFeedTags.CROSS_POST_STATUS), + ) { + Column(Modifier.padding(16.dp)) { + Text( + text = "Cross-post results", + style = MaterialTheme.typography.titleSmall, + ) + Spacer(Modifier.height(8.dp)) + statuses.forEach { status -> + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + val icon = if (status.isFailed) Icons.Filled.Close else Icons.Filled.Check + val tint = when { + status.isFailed -> MaterialTheme.colorScheme.error + status.isSuccess -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(16.dp)) + Text( + text = buildString { + append(status.label) + append(": ") + append( + when { + status.isFailed -> status.error ?: "Failed" + status.isSuccess -> "Posted" + else -> status.status + }, + ) + }, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + Spacer(Modifier.height(4.dp)) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + TextButton(onClick = onDismiss) { Text("Dismiss") } + } + } + } + } +} + /** * Toggle chip for scheduling. To stay device- and dialog-independent (and easily * testable), tapping sets a fixed "1 hour from now" ISO time; tapping again clears diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedViewModel.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedViewModel.kt index f0b2e05..09d6af8 100644 --- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedViewModel.kt +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedViewModel.kt @@ -5,6 +5,9 @@ import androidx.lifecycle.viewModelScope import com.interlinedlist.android.core.common.result.ApiResult import com.interlinedlist.android.core.common.result.AppError import com.interlinedlist.android.feature.messages.data.MessagesRepository +import com.interlinedlist.android.feature.messages.domain.CrossPostSelection +import com.interlinedlist.android.feature.messages.domain.CrossPostStatus +import com.interlinedlist.android.feature.messages.domain.LinkedNetwork import com.interlinedlist.android.feature.messages.domain.Message import com.interlinedlist.android.feature.messages.domain.ReportReason import com.interlinedlist.android.feature.messages.ui.isSubscriptionGate @@ -44,9 +47,23 @@ data class MessagesFeedUiState( val attachments: List = emptyList(), /** Optional future send time (ISO-8601) for the in-progress compose. */ val scheduledAt: String? = null, + /** The caller's already-linked networks, offered as cross-post destinations. */ + val linkedNetworks: List = emptyList(), + /** Ids of the linked networks currently selected as cross-post targets. */ + val selectedNetworkIds: Set = emptySet(), + /** Per-network delivery statuses from the last successful post, if any. */ + val crossPostStatuses: List = emptyList(), /** The message currently being reported (drives the report dialog), if any. */ val reportTarget: Message? = null, val isReporting: Boolean = false, + /** The message currently being edited in-place (drives the edit sheet), if any. */ + val editTarget: Message? = null, + /** The working edit text seeded from [editTarget]'s content. */ + val editText: String = "", + val isSavingEdit: Boolean = false, + /** A pending author-moderation action awaiting confirmation, if any. */ + val moderationTarget: ModerationTarget? = null, + val isModerating: Boolean = false, ) { val isEmpty: Boolean get() = messages.isEmpty() val hasAttachments: Boolean get() = attachments.isNotEmpty() @@ -55,8 +72,29 @@ data class MessagesFeedUiState( val canPost: Boolean get() = (composeText.isNotBlank() || attachments.any { it.hostedUrl != null }) && !isPosting && !isUploading + + /** True when the account has no linked networks to cross-post to. */ + val hasNoLinkedNetworks: Boolean get() = linkedNetworks.isEmpty() + + /** Edit can be saved when the text is non-blank and not currently saving. */ + val canSaveEdit: Boolean get() = editText.isNotBlank() && !isSavingEdit +} + +/** + * A pending author-moderation action, captured when the user taps Block / Mute / + * Report on someone else's message. Drives a confirm dialog before the call runs. + */ +data class ModerationTarget( + val message: Message, + val action: ModerationAction, +) { + val username: String get() = message.authorUsername + val authorLabel: String get() = message.authorLabel } +/** The three author-level moderation actions (distinct from reporting a message). */ +enum class ModerationAction { BLOCK, MUTE, REPORT } + /** Transient (non-cached) UI flags kept separate from the Room-backed message list. */ private data class FeedTransientState( val isRefreshing: Boolean = false, @@ -69,8 +107,16 @@ private data class FeedTransientState( val isPosting: Boolean = false, val attachments: List = emptyList(), val scheduledAt: String? = null, + val linkedNetworks: List = emptyList(), + val selectedNetworkIds: Set = emptySet(), + val crossPostStatuses: List = emptyList(), val reportTarget: Message? = null, val isReporting: Boolean = false, + val editTarget: Message? = null, + val editText: String = "", + val isSavingEdit: Boolean = false, + val moderationTarget: ModerationTarget? = null, + val isModerating: Boolean = false, ) @HiltViewModel @@ -98,8 +144,16 @@ class MessagesFeedViewModel @Inject constructor( isPosting = t.isPosting, attachments = t.attachments, scheduledAt = t.scheduledAt, + linkedNetworks = t.linkedNetworks, + selectedNetworkIds = t.selectedNetworkIds, + crossPostStatuses = t.crossPostStatuses, reportTarget = t.reportTarget, isReporting = t.isReporting, + editTarget = t.editTarget, + editText = t.editText, + isSavingEdit = t.isSavingEdit, + moderationTarget = t.moderationTarget, + isModerating = t.isModerating, ) }.stateIn( scope = viewModelScope, @@ -109,6 +163,29 @@ class MessagesFeedViewModel @Inject constructor( init { refresh() + loadLinkedNetworks() + } + + /** + * Loads the caller's already-linked networks so the composer can offer them as + * cross-post destinations. Best-effort: a failure just leaves the list empty + * (the composer then shows the "link accounts on the web" hint) and does not + * surface a feed-level error. + */ + fun loadLinkedNetworks() { + viewModelScope.launch { + when (val result = repository.getLinkedNetworks()) { + is ApiResult.Success -> transient.update { state -> + // Prune any selections whose network is no longer linked. + val liveIds = result.data.map { it.id }.toSet() + state.copy( + linkedNetworks = result.data, + selectedNetworkIds = state.selectedNetworkIds.intersect(liveIds), + ) + } + is ApiResult.Failure -> Unit + } + } } fun refresh() { @@ -162,14 +239,36 @@ class MessagesFeedViewModel @Inject constructor( // --- compose sheet ----------------------------------------------------- - fun openCompose() = transient.update { it.copy(isComposeOpen = true, errorMessage = null) } + fun openCompose() = transient.update { + it.copy(isComposeOpen = true, errorMessage = null, crossPostStatuses = emptyList()) + } fun dismissCompose() = transient.update { - it.copy(isComposeOpen = false, composeText = "", attachments = emptyList(), scheduledAt = null) + it.copy( + isComposeOpen = false, + composeText = "", + attachments = emptyList(), + scheduledAt = null, + selectedNetworkIds = emptySet(), + ) } fun onComposeTextChange(value: String) = transient.update { it.copy(composeText = value) } + /** + * Toggles a linked network as a cross-post target for the in-progress compose. + * No-ops for an id that isn't currently linked. + */ + fun onToggleNetwork(networkId: String) = transient.update { state -> + if (state.linkedNetworks.none { it.id == networkId }) return@update state + val selected = if (networkId in state.selectedNetworkIds) { + state.selectedNetworkIds - networkId + } else { + state.selectedNetworkIds + networkId + } + state.copy(selectedNetworkIds = selected) + } + /** Sets (or clears with null) the future send time for the in-progress compose. */ fun onScheduleChange(isoTimestamp: String?) = transient.update { it.copy(scheduledAt = isoTimestamp) } @@ -224,7 +323,10 @@ class MessagesFeedViewModel @Inject constructor( if (snapshot.attachments.any { it.isUploading }) return val images = snapshot.attachments.filterNot { it.isVideo }.mapNotNull { it.hostedUrl } val videos = snapshot.attachments.filter { it.isVideo }.mapNotNull { it.hostedUrl } - transient.update { it.copy(isPosting = true, errorMessage = null) } + // Fold the selected linked networks into the cross-post request fields. + val selected = snapshot.linkedNetworks.filter { it.id in snapshot.selectedNetworkIds } + val crossPost = CrossPostSelection.from(selected) + transient.update { it.copy(isPosting = true, errorMessage = null, crossPostStatuses = emptyList()) } viewModelScope.launch { when ( val result = repository.createMessage( @@ -232,6 +334,7 @@ class MessagesFeedViewModel @Inject constructor( imageUrls = images, videoUrls = videos, scheduledAt = snapshot.scheduledAt, + crossPost = crossPost, ) ) { is ApiResult.Success -> transient.update { @@ -241,6 +344,8 @@ class MessagesFeedViewModel @Inject constructor( composeText = "", attachments = emptyList(), scheduledAt = null, + selectedNetworkIds = emptySet(), + crossPostStatuses = result.data.crossPosts, ) } is ApiResult.Failure -> transient.update { @@ -250,6 +355,9 @@ class MessagesFeedViewModel @Inject constructor( } } + /** Dismisses the post-send cross-post status banner. */ + fun dismissCrossPostStatuses() = transient.update { it.copy(crossPostStatuses = emptyList()) } + // --- report ------------------------------------------------------------ fun openReport(message: Message) = transient.update { it.copy(reportTarget = message, errorMessage = null) } @@ -271,6 +379,74 @@ class MessagesFeedViewModel @Inject constructor( } } + // --- edit own message -------------------------------------------------- + + /** Opens the in-place editor seeded with [message]'s current content. */ + fun openEdit(message: Message) = transient.update { + it.copy(editTarget = message, editText = message.content, errorMessage = null) + } + + fun onEditTextChange(value: String) = transient.update { it.copy(editText = value) } + + fun dismissEdit() = transient.update { + it.copy(editTarget = null, editText = "", isSavingEdit = false) + } + + /** Saves the edit: PATCHes the new content (repository updates the cache). */ + fun saveEdit() { + val target = transient.value.editTarget ?: return + val text = transient.value.editText.trim() + if (text.isBlank()) return + transient.update { it.copy(isSavingEdit = true, errorMessage = null) } + viewModelScope.launch { + when (val result = repository.editMessage(target.id, text)) { + is ApiResult.Success -> transient.update { + it.copy(isSavingEdit = false, editTarget = null, editText = "") + } + is ApiResult.Failure -> transient.update { + it.copy(isSavingEdit = false).withError(result.error) + } + } + } + } + + // --- author moderation ------------------------------------------------- + + /** Queues a Block / Mute / Report-user action for confirmation. */ + fun openModeration(message: Message, action: ModerationAction) = transient.update { + it.copy(moderationTarget = ModerationTarget(message, action), errorMessage = null) + } + + fun dismissModeration() = transient.update { + it.copy(moderationTarget = null, isModerating = false) + } + + /** + * Confirms the queued moderation action. Block/Mute additionally hide the + * author's messages from the local feed (handled in the repository); Report + * carries the chosen [reason] and optional [detail]. + */ + fun confirmModeration(reason: ReportReason? = null, detail: String = "") { + val target = transient.value.moderationTarget ?: return + transient.update { it.copy(isModerating = true, errorMessage = null) } + viewModelScope.launch { + val result = when (target.action) { + ModerationAction.BLOCK -> repository.blockUser(target.username) + ModerationAction.MUTE -> repository.muteUser(target.username) + ModerationAction.REPORT -> + repository.reportUser(target.username, reason ?: ReportReason.OTHER, detail) + } + when (result) { + is ApiResult.Success -> transient.update { + it.copy(isModerating = false, moderationTarget = null) + } + is ApiResult.Failure -> transient.update { + it.copy(isModerating = false, moderationTarget = null).withError(result.error) + } + } + } + } + // --- link metadata ----------------------------------------------------- /** Fetches link-preview metadata for a message; the cache Flow re-emits it. */ diff --git a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/DefaultMessagesRepositoryTest.kt b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/DefaultMessagesRepositoryTest.kt index 73cbd0a..75dc8c5 100644 --- a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/DefaultMessagesRepositoryTest.kt +++ b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/DefaultMessagesRepositoryTest.kt @@ -4,6 +4,8 @@ import com.google.common.truth.Truth.assertThat import com.interlinedlist.android.core.common.result.ApiResult import com.interlinedlist.android.core.common.result.AppError import com.interlinedlist.android.feature.messages.data.remote.MessagesApi +import com.interlinedlist.android.feature.messages.domain.CrossPostSelection +import com.interlinedlist.android.feature.messages.domain.NetworkProvider import com.interlinedlist.android.feature.messages.domain.ReportReason import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -23,7 +25,13 @@ import retrofit2.Retrofit class DefaultMessagesRepositoryTest { private val dispatcher = StandardTestDispatcher() - private val json = Json { ignoreUnknownKeys = true; explicitNulls = false } + // Mirrors the production Json (see core:network NetworkModule): coerce explicit + // nulls (e.g. `crossPosts: null`) to the property's default rather than failing. + private val json = Json { + ignoreUnknownKeys = true + explicitNulls = false + coerceInputValues = true + } private lateinit var server: MockWebServer private lateinit var api: MessagesApi @@ -130,17 +138,20 @@ class DefaultMessagesRepositoryTest { """{ "data": [ { "id": "old", "content": "old" } ], "pagination": { "hasMore": false } }""", ) + // The create endpoint returns the new message under `data` (and keys the + // author sub-object as `user`); `message` is a status string. enqueueJson( 201, - """{ "message": { "id": "new", "content": "brand new", - "author": { "id": "me", "username": "me" } } }""", + """{ "message": "Message created successfully", + "data": { "id": "new", "content": "brand new", + "user": { "id": "me", "username": "me" } } }""", ) val repo = repository() repo.refreshFeed() val result = repo.createMessage("brand new") - assertThat((result as ApiResult.Success).data.id).isEqualTo("new") + assertThat((result as ApiResult.Success).data.message.id).isEqualTo("new") val ids = repo.observeFeed().first().map { it.id } assertThat(ids.first()).isEqualTo("new") } @@ -210,7 +221,8 @@ class DefaultMessagesRepositoryTest { ) enqueueJson( 201, - """{ "message": { "id": "r", "content": "a reply", "author": { "id": "me" } } }""", + """{ "message": "Message created successfully", + "data": { "id": "r", "content": "a reply", "user": { "id": "me" } } }""", ) val repo = repository() repo.refreshFeed() @@ -274,8 +286,9 @@ class DefaultMessagesRepositoryTest { fun `createMessage with media sends the attached urls`() = runTest(dispatcher) { enqueueJson( 201, - """{ "message": { "id": "m1", "content": "with media", - "imageUrls": ["https://cdn/a.png"] } }""", + """{ "message": "Message created successfully", + "data": { "id": "m1", "content": "with media", + "imageUrls": ["https://cdn/a.png"] } }""", ) val repo = repository() @@ -284,7 +297,7 @@ class DefaultMessagesRepositoryTest { imageUrls = listOf("https://cdn/a.png"), ) - assertThat((result as ApiResult.Success).data.imageUrls).containsExactly("https://cdn/a.png") + assertThat((result as ApiResult.Success).data.message.imageUrls).containsExactly("https://cdn/a.png") val body = server.takeRequest().body.readUtf8() assertThat(body).contains("https://cdn/a.png") assertThat(body).contains("imageUrls") @@ -294,14 +307,15 @@ class DefaultMessagesRepositoryTest { fun `createMessage scheduled is cached in the scheduled view not the feed`() = runTest(dispatcher) { enqueueJson( 201, - """{ "message": { "id": "sch1", "content": "later", - "scheduledAt": "2026-07-19T09:00:00Z" } }""", + """{ "message": "Message created successfully", + "data": { "id": "sch1", "content": "later", + "scheduledAt": "2026-07-19T09:00:00Z" } }""", ) val repo = repository() val result = repo.createMessage(content = "later", scheduledAt = "2026-07-19T09:00:00Z") - assertThat((result as ApiResult.Success).data.scheduledAt).isEqualTo("2026-07-19T09:00:00Z") + assertThat((result as ApiResult.Success).data.message.scheduledAt).isEqualTo("2026-07-19T09:00:00Z") assertThat(repo.observeFeed().first()).isEmpty() assertThat(repo.observeScheduled().first().map { it.id }).containsExactly("sch1") } @@ -363,6 +377,276 @@ class DefaultMessagesRepositoryTest { assertThat(body).doesNotContain("detail") } + @Test + fun `editMessage PATCHes the content and updates the cached message`() = runTest(dispatcher) { + enqueueJson( + 200, + """{ "data": [ { "id": "1", "content": "original", "author": { "id": "me", "username": "me" } } ], + "pagination": { "hasMore": false } }""", + ) + enqueueJson(200, "") // PATCH response body is not modelled; a 2xx is success. + val repo = repository() + repo.refreshFeed() + + val result = repo.editMessage("1", content = "edited body") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + assertThat((result as ApiResult.Success).data.content).isEqualTo("edited body") + // The cache reflects the new content and now carries an "edited" marker. + val cached = repo.observeMessage("1").first() + assertThat(cached?.content).isEqualTo("edited body") + assertThat(cached?.isEdited).isTrue() + + server.takeRequest() // the refresh GET + val patch = server.takeRequest() + assertThat(patch.method).isEqualTo("PATCH") + assertThat(patch.path).contains("api/messages/1") + assertThat(patch.body.readUtf8()).contains("\"content\":\"edited body\"") + } + + @Test + fun `editMessage rolls back the cached content on failure`() = runTest(dispatcher) { + enqueueJson( + 200, + """{ "data": [ { "id": "1", "content": "original", "author": { "id": "me", "username": "me" } } ], + "pagination": { "hasMore": false } }""", + ) + enqueueJson(500, """{ "error": "boom" }""") + val repo = repository() + repo.refreshFeed() + + val result = repo.editMessage("1", content = "will not stick") + + assertThat(result).isInstanceOf(ApiResult.Failure::class.java) + val cached = repo.observeMessage("1").first() + assertThat(cached?.content).isEqualTo("original") + assertThat(cached?.isEdited).isFalse() + } + + @Test + fun `blockUser posts and hides the author's messages from the feed`() = runTest(dispatcher) { + enqueueJson( + 200, + """{ "data": [ + { "id": "1", "content": "by amy", "author": { "id": "a", "username": "amy" } }, + { "id": "2", "content": "by bob", "author": { "id": "b", "username": "bob" } } + ], "pagination": { "hasMore": false } }""", + ) + enqueueJson(201, "") + val repo = repository() + repo.refreshFeed() + + val result = repo.blockUser("amy") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val request = server.let { it.takeRequest(); it.takeRequest() } + assertThat(request.method).isEqualTo("POST") + assertThat(request.path).contains("api/users/amy/block") + // Amy's message is gone; bob's remains. + assertThat(repo.observeFeed().first().map { it.id }).containsExactly("2") + } + + @Test + fun `muteUser posts and hides the author's messages from the feed`() = runTest(dispatcher) { + enqueueJson( + 200, + """{ "data": [ + { "id": "1", "content": "by amy", "author": { "id": "a", "username": "amy" } } + ], "pagination": { "hasMore": false } }""", + ) + enqueueJson(201, "") + val repo = repository() + repo.refreshFeed() + + val result = repo.muteUser("amy") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + server.takeRequest() + assertThat(server.takeRequest().path).contains("api/users/amy/mute") + assertThat(repo.observeFeed().first()).isEmpty() + } + + @Test + fun `blockUser failure leaves the feed intact`() = runTest(dispatcher) { + enqueueJson( + 200, + """{ "data": [ + { "id": "1", "content": "by amy", "author": { "id": "a", "username": "amy" } } + ], "pagination": { "hasMore": false } }""", + ) + enqueueJson(500, """{ "error": "boom" }""") + val repo = repository() + repo.refreshFeed() + + val result = repo.blockUser("amy") + + assertThat(result).isInstanceOf(ApiResult.Failure::class.java) + assertThat(repo.observeFeed().first().map { it.id }).containsExactly("1") + } + + @Test + fun `reportUser posts the reason and detail to the user report endpoint`() = runTest(dispatcher) { + enqueueJson(201, "") + val repo = repository() + + val result = repo.reportUser("amy", ReportReason.HARASSMENT, detail = "abusive dms") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val request = server.takeRequest() + assertThat(request.path).contains("api/users/amy/report") + val body = request.body.readUtf8() + assertThat(body).contains("\"reason\":\"harassment\"") + assertThat(body).contains("abusive dms") + } + + @Test + fun `reportUser omits blank detail`() = runTest(dispatcher) { + enqueueJson(201, "") + val repo = repository() + + repo.reportUser("amy", ReportReason.OTHER, detail = " ") + + val body = server.takeRequest().body.readUtf8() + assertThat(body).doesNotContain("detail") + } + + // --- cross-posting ----------------------------------------------------- + + @Test + fun `getLinkedNetworks parses varied providers`() = runTest(dispatcher) { + enqueueJson( + 200, + """ + { "identities": [ + { "id": "m1", "provider": "mastodon:techhub.social", + "providerUsername": "crew@techhub.social", + "profileUrl": "https://techhub.social/@crew", "avatarUrl": null, + "connectedAt": "2026-04-07T16:35:32.476Z", "lastVerifiedAt": null }, + { "id": "l1", "provider": "linkedin", "providerUsername": "Adron Hall" }, + { "id": "t1", "provider": "twitter", "providerUsername": "interlinedlist" }, + { "id": "b1", "provider": "bluesky", "providerUsername": "il.bsky.social" } + ] } + """.trimIndent(), + ) + val repo = repository() + + val result = repo.getLinkedNetworks() + + val networks = (result as ApiResult.Success).data + assertThat(networks.map { it.id }).containsExactly("m1", "l1", "t1", "b1").inOrder() + assertThat(networks.map { it.networkProvider }).containsExactly( + NetworkProvider.MASTODON, + NetworkProvider.LINKEDIN, + NetworkProvider.TWITTER, + NetworkProvider.BLUESKY, + ).inOrder() + // The mastodon chip label surfaces the instance host. + assertThat(networks.first().chipLabel).isEqualTo("techhub.social") + assertThat(server.takeRequest().path).contains("api/user/identities") + } + + @Test + fun `getLinkedNetworks returns empty when nothing is linked`() = runTest(dispatcher) { + enqueueJson(200, """{ "identities": [] }""") + val repo = repository() + + val result = repo.getLinkedNetworks() + + assertThat((result as ApiResult.Success).data).isEmpty() + } + + @Test + fun `createMessage with targets sends the cross-post fields`() = runTest(dispatcher) { + enqueueJson( + 201, + """{ "message": "Message created successfully", + "data": { "id": "x1", "content": "cross-posted" } }""", + ) + val repo = repository() + + val result = repo.createMessage( + content = "cross-posted", + crossPost = CrossPostSelection( + mastodonProviderIds = listOf("m1"), + linkedIn = true, + twitter = true, + ), + ) + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val body = server.takeRequest().body.readUtf8() + assertThat(body).contains("\"mastodonProviderIds\":[\"m1\"]") + assertThat(body).contains("\"crossPostToLinkedIn\":true") + assertThat(body).contains("\"crossPostToTwitter\":true") + // Unselected networks are omitted entirely (explicitNulls = false). + assertThat(body).doesNotContain("crossPostToBluesky") + } + + @Test + fun `createMessage without targets keeps the original body`() = runTest(dispatcher) { + enqueueJson( + 201, + """{ "message": "Message created successfully", + "data": { "id": "plain", "content": "just il" } }""", + ) + val repo = repository() + + repo.createMessage(content = "just il") + + val body = server.takeRequest().body.readUtf8() + assertThat(body).contains("\"content\":\"just il\"") + // None of the cross-post fields are present on a plain post. + assertThat(body).doesNotContain("mastodonProviderIds") + assertThat(body).doesNotContain("crossPostToBluesky") + assertThat(body).doesNotContain("crossPostToLinkedIn") + assertThat(body).doesNotContain("crossPostToTwitter") + } + + @Test + fun `createMessage parses the crossPosts statuses from the response`() = runTest(dispatcher) { + enqueueJson( + 201, + """{ "message": "Message created successfully", + "data": { "id": "x2", "content": "cross-posted" }, + "crossPosts": [ + { "provider": "linkedin", "status": "success", + "url": "https://linkedin.com/post/1" }, + { "provider": "mastodon:techhub.social", "status": "pending" }, + { "provider": "twitter", "status": "failed", "error": "rate limited" } + ] }""", + ) + val repo = repository() + + val result = repo.createMessage( + content = "cross-posted", + crossPost = CrossPostSelection(linkedIn = true, twitter = true), + ) + + val created = (result as ApiResult.Success).data + assertThat(created.crossPosts.map { it.provider }) + .containsExactly("linkedin", "mastodon:techhub.social", "twitter").inOrder() + val linkedIn = created.crossPosts.first { it.provider == "linkedin" } + assertThat(linkedIn.isSuccess).isTrue() + assertThat(linkedIn.url).isEqualTo("https://linkedin.com/post/1") + val twitter = created.crossPosts.first { it.provider == "twitter" } + assertThat(twitter.isFailed).isTrue() + assertThat(twitter.error).isEqualTo("rate limited") + } + + @Test + fun `createMessage defaults crossPosts to empty when absent`() = runTest(dispatcher) { + enqueueJson( + 201, + """{ "message": "Message created successfully", + "data": { "id": "x3", "content": "plain" }, "crossPosts": null }""", + ) + val repo = repository() + + val result = repo.createMessage(content = "plain") + + assertThat((result as ApiResult.Success).data.crossPosts).isEmpty() + } + @Test fun `fetchMetadata attaches a link preview to the cached message`() = runTest(dispatcher) { enqueueJson( diff --git a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/FakeMessageDao.kt b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/FakeMessageDao.kt index ca684ea..d395836 100644 --- a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/FakeMessageDao.kt +++ b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/FakeMessageDao.kt @@ -46,6 +46,10 @@ class FakeMessageDao : MessageDao { rows.value = rows.value.toMutableMap().apply { remove(id) } } + override suspend fun deleteByAuthorUsername(username: String) { + rows.value = rows.value.filterValues { it.authorUsername != username } + } + override suspend fun clearFeed() { rows.value = rows.value.filterValues { it.parentId != null || it.scheduledAt != null } } diff --git a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessageDtoMapperTest.kt b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessageDtoMapperTest.kt index f60a9e1..65c8df4 100644 --- a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessageDtoMapperTest.kt +++ b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessageDtoMapperTest.kt @@ -118,6 +118,43 @@ class MessageDtoMapperTest { assertThat(message.linkPreview).isNull() } + @Test + fun `flags an edited message when updatedAt differs from createdAt`() { + val message = MessageDto( + id = "m8", + content = "edited", + createdAt = "2026-07-18T10:00:00Z", + updatedAt = "2026-07-18T11:30:00Z", + ).toDomain(currentUserId = null) + + assertThat(message.isEdited).isTrue() + assertThat(message.editedAt).isEqualTo("2026-07-18T11:30:00Z") + } + + @Test + fun `does not flag as edited when updatedAt equals createdAt`() { + val message = MessageDto( + id = "m9", + content = "fresh", + createdAt = "2026-07-18T10:00:00Z", + updatedAt = "2026-07-18T10:00:00Z", + ).toDomain(currentUserId = null) + + assertThat(message.isEdited).isFalse() + assertThat(message.editedAt).isNull() + } + + @Test + fun `is not edited when updatedAt is absent`() { + val message = MessageDto( + id = "m10", + content = "no updatedAt", + createdAt = "2026-07-18T10:00:00Z", + ).toDomain(currentUserId = null) + + assertThat(message.isEdited).isFalse() + } + @Test fun `drops a link preview without a url`() { val message = MessageDto( diff --git a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/FakeMessagesRepository.kt b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/FakeMessagesRepository.kt index 44cb89f..c0928fb 100644 --- a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/FakeMessagesRepository.kt +++ b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/FakeMessagesRepository.kt @@ -3,6 +3,10 @@ package com.interlinedlist.android.feature.messages.ui import com.interlinedlist.android.core.common.result.ApiResult import com.interlinedlist.android.core.common.result.AppError import com.interlinedlist.android.feature.messages.data.MessagesRepository +import com.interlinedlist.android.feature.messages.domain.CreatedMessage +import com.interlinedlist.android.feature.messages.domain.CrossPostSelection +import com.interlinedlist.android.feature.messages.domain.CrossPostStatus +import com.interlinedlist.android.feature.messages.domain.LinkedNetwork import com.interlinedlist.android.feature.messages.domain.Message import com.interlinedlist.android.feature.messages.domain.ReportReason import kotlinx.coroutines.flow.Flow @@ -24,11 +28,18 @@ class FakeMessagesRepository : MessagesRepository { var refreshResult: ApiResult = ApiResult.Success(false) var loadMoreResult: ApiResult = ApiResult.Success(false) var createResult: ApiResult? = null + /** Cross-post statuses returned alongside a successful [createResult]. */ + var createCrossPosts: List = emptyList() + var linkedNetworksResult: ApiResult> = ApiResult.Success(emptyList()) var fetchResult: ApiResult? = null var refreshRepliesResult: ApiResult = ApiResult.Success(Unit) var postReplyResult: ApiResult? = null var setDugResult: ApiResult = ApiResult.Success(Unit) var deleteResult: ApiResult = ApiResult.Success(Unit) + var editResult: ApiResult? = null + var blockResult: ApiResult = ApiResult.Success(Unit) + var muteResult: ApiResult = ApiResult.Success(Unit) + var reportUserResult: ApiResult = ApiResult.Success(Unit) var searchResult: ApiResult> = ApiResult.Success(emptyList()) var uploadImageResult: ApiResult = ApiResult.Success("https://cdn/image.png") var uploadVideoResult: ApiResult = ApiResult.Success("https://cdn/video.mp4") @@ -48,6 +59,10 @@ class FakeMessagesRepository : MessagesRepository { var cancelledScheduledIds = mutableListOf() var lastReport: ReportArgs? = null var metadataFetchedIds = mutableListOf() + var lastEdit: Pair? = null + var blockedUsernames = mutableListOf() + var mutedUsernames = mutableListOf() + var lastReportUser: ReportUserArgs? = null /** Snapshot of the arguments passed to the last [createMessage] call. */ data class CreateArgs( @@ -55,11 +70,15 @@ class FakeMessagesRepository : MessagesRepository { val imageUrls: List, val videoUrls: List, val scheduledAt: String?, + val crossPost: CrossPostSelection = CrossPostSelection.NONE, ) /** Snapshot of the arguments passed to the last [report] call. */ data class ReportArgs(val messageId: String, val reason: ReportReason, val detail: String?) + /** Snapshot of the arguments passed to the last [reportUser] call. */ + data class ReportUserArgs(val username: String, val reason: ReportReason, val detail: String?) + fun emitFeed(messages: List) { feed.value = messages } fun emitReplies(parentId: String, messages: List) { replies.value = replies.value + (parentId to messages) @@ -92,11 +111,18 @@ class FakeMessagesRepository : MessagesRepository { imageUrls: List, videoUrls: List, scheduledAt: String?, - ): ApiResult { - lastCreate = CreateArgs(content, imageUrls, videoUrls, scheduledAt) - return createResult ?: ApiResult.Failure(AppError.Unknown("createResult not set")) + crossPost: CrossPostSelection, + ): ApiResult { + lastCreate = CreateArgs(content, imageUrls, videoUrls, scheduledAt, crossPost) + return when (val result = createResult) { + is ApiResult.Success -> ApiResult.Success(CreatedMessage(result.data, createCrossPosts)) + is ApiResult.Failure -> result + null -> ApiResult.Failure(AppError.Unknown("createResult not set")) + } } + override suspend fun getLinkedNetworks(): ApiResult> = linkedNetworksResult + override suspend fun uploadImage(bytes: ByteArray, fileName: String, mimeType: String): ApiResult { uploadedImages++ return uploadImageResult @@ -125,6 +151,17 @@ class FakeMessagesRepository : MessagesRepository { return deleteResult } + override suspend fun editMessage(messageId: String, content: String): ApiResult { + lastEdit = messageId to content + val result = editResult ?: ApiResult.Failure(AppError.Unknown("editResult not set")) + if (result is ApiResult.Success) { + // Reflect the edit into the observable feed/message so the UI re-emits. + feed.value = feed.value.map { if (it.id == messageId) result.data else it } + single.value = single.value + (messageId to result.data) + } + return result + } + override suspend fun refreshScheduled(): ApiResult { refreshScheduledCount++ return refreshScheduledResult @@ -140,6 +177,30 @@ class FakeMessagesRepository : MessagesRepository { return reportResult } + override suspend fun blockUser(username: String): ApiResult { + blockedUsernames += username + val result = blockResult + if (result is ApiResult.Success) { + // Mirror the repository's hide-on-block behaviour for ViewModel tests. + feed.value = feed.value.filterNot { it.authorUsername == username } + } + return result + } + + override suspend fun muteUser(username: String): ApiResult { + mutedUsernames += username + val result = muteResult + if (result is ApiResult.Success) { + feed.value = feed.value.filterNot { it.authorUsername == username } + } + return result + } + + override suspend fun reportUser(username: String, reason: ReportReason, detail: String?): ApiResult { + lastReportUser = ReportUserArgs(username, reason, detail) + return reportUserResult + } + override suspend fun fetchMetadata(messageId: String): ApiResult { metadataFetchedIds += messageId return metadataResult ?: ApiResult.Failure(AppError.Unknown("metadataResult not set")) @@ -148,6 +209,17 @@ class FakeMessagesRepository : MessagesRepository { override suspend fun search(query: String): ApiResult> = searchResult } +/** Builds a sample [LinkedNetwork] for tests. */ +fun sampleNetwork( + id: String, + provider: String, + providerUsername: String = "handle", +) = LinkedNetwork( + id = id, + provider = provider, + providerUsername = providerUsername, +) + /** Builds a sample [Message] for tests. */ fun sampleMessage( id: String = "1", @@ -160,11 +232,13 @@ fun sampleMessage( imageUrls: List = emptyList(), videoUrls: List = emptyList(), scheduledAt: String? = null, + authorUsername: String = "adron", + editedAt: String? = null, ) = Message( id = id, content = content, authorId = "u1", - authorUsername = "adron", + authorUsername = authorUsername, authorDisplayName = "Adron", authorAvatarUrl = null, createdAt = null, @@ -176,4 +250,5 @@ fun sampleMessage( imageUrls = imageUrls, videoUrls = videoUrls, scheduledAt = scheduledAt, + editedAt = editedAt, ) diff --git a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/detail/MessageDetailViewModelTest.kt b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/detail/MessageDetailViewModelTest.kt index 45450d6..815647a 100644 --- a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/detail/MessageDetailViewModelTest.kt +++ b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/detail/MessageDetailViewModelTest.kt @@ -6,6 +6,7 @@ import com.interlinedlist.android.core.common.result.ApiResult import com.interlinedlist.android.core.common.result.AppError import com.interlinedlist.android.feature.messages.domain.ReportReason import com.interlinedlist.android.feature.messages.ui.FakeMessagesRepository +import com.interlinedlist.android.feature.messages.ui.feed.ModerationAction import com.interlinedlist.android.feature.messages.ui.sampleMessage import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -148,6 +149,92 @@ class MessageDetailViewModelTest { assertThat(vm.uiState.value.reportTarget).isNull() } + @Test + fun `edit seeds and saves the current message content`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { + fetchResult = ApiResult.Success(sampleMessage(id = "m1", content = "original", mine = true)) + editResult = ApiResult.Success( + sampleMessage(id = "m1", content = "edited", mine = true, editedAt = "2026-07-31T12:00:00Z"), + ) + } + repo.emitMessage(sampleMessage(id = "m1", content = "original", mine = true)) + val vm = MessageDetailViewModel(repo, handle("m1")) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + vm.openEdit(sampleMessage(id = "m1", content = "original", mine = true)) + advanceUntilIdle() + assertThat(vm.uiState.value.editText).isEqualTo("original") + + vm.onEditTextChange("edited") + vm.saveEdit() + advanceUntilIdle() + + assertThat(repo.lastEdit).isEqualTo("m1" to "edited") + assertThat(vm.uiState.value.editTarget).isNull() + assertThat(vm.uiState.value.message?.content).isEqualTo("edited") + assertThat(vm.uiState.value.message?.isEdited).isTrue() + } + + @Test + fun `edit failure keeps the sheet open and shows an error`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { + fetchResult = ApiResult.Success(sampleMessage(id = "m1", mine = true)) + editResult = ApiResult.Failure(AppError.Server("nope")) + } + val vm = MessageDetailViewModel(repo, handle("m1")) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + vm.openEdit(sampleMessage(id = "m1", content = "original", mine = true)) + vm.onEditTextChange("changed") + vm.saveEdit() + advanceUntilIdle() + + assertThat(vm.uiState.value.editTarget?.id).isEqualTo("m1") + assertThat(vm.uiState.value.errorMessage).isNotEmpty() + } + + @Test + fun `block on a reply delegates to the repository`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { + fetchResult = ApiResult.Success(sampleMessage(id = "m1")) + } + val vm = MessageDetailViewModel(repo, handle("m1")) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + val reply = sampleMessage(id = "r1", parentId = "m1", authorUsername = "amy") + vm.openModeration(reply, ModerationAction.BLOCK) + advanceUntilIdle() + assertThat(vm.uiState.value.moderationTarget?.username).isEqualTo("amy") + + vm.confirmModeration() + advanceUntilIdle() + + assertThat(repo.blockedUsernames).containsExactly("amy") + assertThat(vm.uiState.value.moderationTarget).isNull() + } + + @Test + fun `report user submits the reason and detail`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { + fetchResult = ApiResult.Success(sampleMessage(id = "m1")) + } + val vm = MessageDetailViewModel(repo, handle("m1")) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + vm.openModeration(sampleMessage(id = "m1", authorUsername = "amy"), ModerationAction.REPORT) + vm.confirmModeration(ReportReason.SPAM, "spammer") + advanceUntilIdle() + + val report = repo.lastReportUser!! + assertThat(report.username).isEqualTo("amy") + assertThat(report.reason).isEqualTo(ReportReason.SPAM) + assertThat(report.detail).isEqualTo("spammer") + } + @Test fun `fetchMetadata delegates for the current message`() = runTest(dispatcher) { val repo = FakeMessagesRepository().apply { diff --git a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedViewModelTest.kt b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedViewModelTest.kt index c3e1616..b354d92 100644 --- a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedViewModelTest.kt +++ b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedViewModelTest.kt @@ -4,9 +4,11 @@ import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import com.interlinedlist.android.core.common.result.ApiResult import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.messages.domain.CrossPostStatus import com.interlinedlist.android.feature.messages.domain.ReportReason import com.interlinedlist.android.feature.messages.ui.FakeMessagesRepository import com.interlinedlist.android.feature.messages.ui.sampleMessage +import com.interlinedlist.android.feature.messages.ui.sampleNetwork import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.launch @@ -286,6 +288,275 @@ class MessagesFeedViewModelTest { assertThat(vm.uiState.value.reportTarget).isNull() } + @Test + fun `edit seeds the sheet and saves the new content marking it edited`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { + editResult = ApiResult.Success( + sampleMessage(id = "own", content = "updated body", mine = true, editedAt = "2026-07-31T12:00:00Z"), + ) + } + repo.emitFeed(listOf(sampleMessage(id = "own", content = "original body", mine = true))) + val vm = MessagesFeedViewModel(repo) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + vm.openEdit(sampleMessage(id = "own", content = "original body", mine = true)) + advanceUntilIdle() + // Editor is seeded with the current content. + assertThat(vm.uiState.value.editTarget?.id).isEqualTo("own") + assertThat(vm.uiState.value.editText).isEqualTo("original body") + + vm.onEditTextChange("updated body") + vm.saveEdit() + advanceUntilIdle() + + assertThat(repo.lastEdit).isEqualTo("own" to "updated body") + // Sheet closed and the feed reflects the edited, marked message. + assertThat(vm.uiState.value.editTarget).isNull() + val edited = vm.uiState.value.messages.first { it.id == "own" } + assertThat(edited.content).isEqualTo("updated body") + assertThat(edited.isEdited).isTrue() + } + + @Test + fun `edit failure keeps the sheet open and surfaces an error`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { + editResult = ApiResult.Failure(AppError.Server("nope")) + } + val vm = MessagesFeedViewModel(repo) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + vm.openEdit(sampleMessage(id = "own", content = "original", mine = true)) + vm.onEditTextChange("changed") + vm.saveEdit() + advanceUntilIdle() + + val state = vm.uiState.value + assertThat(state.editTarget?.id).isEqualTo("own") + assertThat(state.isSavingEdit).isFalse() + assertThat(state.errorMessage).isNotEmpty() + } + + @Test + fun `block hides the author's messages from the feed`() = runTest(dispatcher) { + val repo = FakeMessagesRepository() + repo.emitFeed( + listOf( + sampleMessage(id = "1", authorUsername = "amy"), + sampleMessage(id = "2", authorUsername = "bob"), + ), + ) + val vm = MessagesFeedViewModel(repo) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + vm.openModeration(sampleMessage(id = "1", authorUsername = "amy"), ModerationAction.BLOCK) + advanceUntilIdle() + assertThat(vm.uiState.value.moderationTarget?.username).isEqualTo("amy") + + vm.confirmModeration() + advanceUntilIdle() + + assertThat(repo.blockedUsernames).containsExactly("amy") + assertThat(vm.uiState.value.moderationTarget).isNull() + assertThat(vm.uiState.value.messages.map { it.id }).containsExactly("2") + } + + @Test + fun `block failure surfaces an error and keeps the feed`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { + blockResult = ApiResult.Failure(AppError.Server("boom")) + } + repo.emitFeed(listOf(sampleMessage(id = "1", authorUsername = "amy"))) + val vm = MessagesFeedViewModel(repo) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + vm.openModeration(sampleMessage(id = "1", authorUsername = "amy"), ModerationAction.BLOCK) + vm.confirmModeration() + advanceUntilIdle() + + assertThat(vm.uiState.value.errorMessage).isNotEmpty() + assertThat(vm.uiState.value.messages.map { it.id }).containsExactly("1") + } + + @Test + fun `mute delegates to the repository`() = runTest(dispatcher) { + val repo = FakeMessagesRepository() + repo.emitFeed(listOf(sampleMessage(id = "1", authorUsername = "amy"))) + val vm = MessagesFeedViewModel(repo) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + vm.openModeration(sampleMessage(id = "1", authorUsername = "amy"), ModerationAction.MUTE) + vm.confirmModeration() + advanceUntilIdle() + + assertThat(repo.mutedUsernames).containsExactly("amy") + assertThat(vm.uiState.value.messages).isEmpty() + } + + @Test + fun `report user submits the chosen reason and detail`() = runTest(dispatcher) { + val repo = FakeMessagesRepository() + val vm = MessagesFeedViewModel(repo) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + vm.openModeration(sampleMessage(id = "1", authorUsername = "amy"), ModerationAction.REPORT) + vm.confirmModeration(ReportReason.HARASSMENT, "abusive") + advanceUntilIdle() + + val report = repo.lastReportUser!! + assertThat(report.username).isEqualTo("amy") + assertThat(report.reason).isEqualTo(ReportReason.HARASSMENT) + assertThat(report.detail).isEqualTo("abusive") + assertThat(vm.uiState.value.moderationTarget).isNull() + } + + // --- cross-posting ----------------------------------------------------- + + @Test + fun `linked networks are loaded on init`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { + linkedNetworksResult = ApiResult.Success( + listOf( + sampleNetwork(id = "m1", provider = "mastodon:techhub.social"), + sampleNetwork(id = "l1", provider = "linkedin"), + ), + ) + } + val vm = MessagesFeedViewModel(repo) + + vm.uiState.test { + advanceUntilIdle() + val state = expectMostRecentItem() + assertThat(state.linkedNetworks.map { it.id }).containsExactly("m1", "l1").inOrder() + assertThat(state.hasNoLinkedNetworks).isFalse() + } + } + + @Test + fun `empty linked networks yields the no-networks state`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { + linkedNetworksResult = ApiResult.Success(emptyList()) + } + val vm = MessagesFeedViewModel(repo) + + vm.uiState.test { + advanceUntilIdle() + val state = expectMostRecentItem() + assertThat(state.linkedNetworks).isEmpty() + assertThat(state.hasNoLinkedNetworks).isTrue() + } + } + + @Test + fun `toggling a network selects then deselects it`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { + linkedNetworksResult = ApiResult.Success( + listOf(sampleNetwork(id = "l1", provider = "linkedin")), + ) + } + val vm = MessagesFeedViewModel(repo) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + vm.onToggleNetwork("l1") + advanceUntilIdle() + assertThat(vm.uiState.value.selectedNetworkIds).containsExactly("l1") + + vm.onToggleNetwork("l1") + advanceUntilIdle() + assertThat(vm.uiState.value.selectedNetworkIds).isEmpty() + } + + @Test + fun `post includes the selected networks as cross-post targets`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { + linkedNetworksResult = ApiResult.Success( + listOf( + sampleNetwork(id = "m1", provider = "mastodon:techhub.social"), + sampleNetwork(id = "l1", provider = "linkedin"), + sampleNetwork(id = "b1", provider = "bluesky"), + ), + ) + createResult = ApiResult.Success(sampleMessage(id = "new")) + } + val vm = MessagesFeedViewModel(repo) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + vm.openCompose() + vm.onComposeTextChange("hello networks") + vm.onToggleNetwork("m1") + vm.onToggleNetwork("l1") + advanceUntilIdle() + vm.post() + advanceUntilIdle() + + val crossPost = repo.lastCreate!!.crossPost + assertThat(crossPost.mastodonProviderIds).containsExactly("m1") + assertThat(crossPost.linkedIn).isTrue() + // Bluesky was never toggled. + assertThat(crossPost.bluesky).isFalse() + assertThat(crossPost.twitter).isFalse() + // Selection is cleared after a successful post. + assertThat(vm.uiState.value.selectedNetworkIds).isEmpty() + } + + @Test + fun `post with no targets sends an empty selection`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { + linkedNetworksResult = ApiResult.Success( + listOf(sampleNetwork(id = "l1", provider = "linkedin")), + ) + createResult = ApiResult.Success(sampleMessage(id = "new")) + } + val vm = MessagesFeedViewModel(repo) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + vm.openCompose() + vm.onComposeTextChange("just il") + vm.post() + advanceUntilIdle() + + assertThat(repo.lastCreate!!.crossPost.hasTargets).isFalse() + } + + @Test + fun `a successful cross-post surfaces the per-network statuses`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { + linkedNetworksResult = ApiResult.Success( + listOf(sampleNetwork(id = "l1", provider = "linkedin")), + ) + createResult = ApiResult.Success(sampleMessage(id = "new")) + createCrossPosts = listOf( + CrossPostStatus(provider = "linkedin", status = "success", url = "https://li/1"), + ) + } + val vm = MessagesFeedViewModel(repo) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + vm.openCompose() + vm.onComposeTextChange("cross-posted") + vm.onToggleNetwork("l1") + advanceUntilIdle() + vm.post() + advanceUntilIdle() + + val statuses = vm.uiState.value.crossPostStatuses + assertThat(statuses.map { it.provider }).containsExactly("linkedin") + assertThat(statuses.first().isSuccess).isTrue() + + vm.dismissCrossPostStatuses() + advanceUntilIdle() + assertThat(vm.uiState.value.crossPostStatuses).isEmpty() + } + @Test fun `fetchMetadata delegates to the repository`() = runTest(dispatcher) { val repo = FakeMessagesRepository().apply { diff --git a/feature/notifications/build.gradle.kts b/feature/notifications/build.gradle.kts index 827ee9f..f0f0b30 100644 --- a/feature/notifications/build.gradle.kts +++ b/feature/notifications/build.gradle.kts @@ -32,6 +32,10 @@ dependencies { implementation(project(":core:network")) implementation(project(":core:datastore")) + // core-ktx provides NotificationManagerCompat / NotificationCompat and + // getSystemService used by the system-notification poll (push tray). + implementation(libs.androidx.core.ktx) + implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.material3) @@ -53,6 +57,11 @@ dependencies { ksp(libs.hilt.compiler) implementation(libs.androidx.hilt.navigation.compose) + // Background notification polling via WorkManager, with Hilt-injected workers. + implementation(libs.androidx.work.runtime.ktx) + implementation(libs.androidx.hilt.work) + ksp(libs.androidx.hilt.compiler) + implementation(libs.coil.compose) implementation(libs.retrofit.core) diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/DefaultNotificationsRepository.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/DefaultNotificationsRepository.kt index 72c20e2..c8e8d7d 100644 --- a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/DefaultNotificationsRepository.kt +++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/DefaultNotificationsRepository.kt @@ -30,6 +30,13 @@ class DefaultNotificationsRepository @Inject constructor( override fun observeUnreadCount(): Flow = notificationDao.observeUnreadCount() + override suspend fun fetchLatest(): ApiResult> = withContext(dispatchers.io) { + when (val result = safeCall { api.getNotifications(limit = PaginationDto.DEFAULT_LIMIT, offset = 0) }) { + is ApiResult.Success -> ApiResult.Success(result.data.items.map { it.toDomain() }) + is ApiResult.Failure -> result + } + } + override suspend fun refresh(): ApiResult = withContext(dispatchers.io) { when (val result = safeCall { api.getNotifications(limit = PaginationDto.DEFAULT_LIMIT, offset = 0) }) { is ApiResult.Success -> { diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/NotificationsRepository.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/NotificationsRepository.kt index 14d128d..0566ada 100644 --- a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/NotificationsRepository.kt +++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/NotificationsRepository.kt @@ -21,6 +21,13 @@ interface NotificationsRepository { /** Live count of unread notifications (drives the badge/header styling). */ fun observeUnreadCount(): Flow + /** + * Fetches the first page of notifications from the API as domain models WITHOUT + * touching the Room cache. Used by the background poll worker, which must not + * disturb the offline-first feed the UI observes. + */ + suspend fun fetchLatest(): ApiResult> + /** * Refreshes the first page from the API and replaces the cached list. * Returns whether more pages are available. diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/remote/dto/NotificationsResponse.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/remote/dto/NotificationsResponse.kt index 87fe0fd..3df79ce 100644 --- a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/remote/dto/NotificationsResponse.kt +++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/remote/dto/NotificationsResponse.kt @@ -1,5 +1,6 @@ package com.interlinedlist.android.feature.notifications.data.remote.dto +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable /** @@ -12,6 +13,8 @@ import kotlinx.serialization.Serializable */ @Serializable data class NotificationsResponse( + /** Primary key the live API uses: `{ unreadCount, items: [...] }`. */ + @SerialName("items") private val itemsKey: List = emptyList(), val data: List = emptyList(), /** Alternate key some payloads use for the list. */ val notifications: List = emptyList(), @@ -19,8 +22,8 @@ data class NotificationsResponse( /** Server-provided unread count, when present; otherwise derived from [items]. */ val unreadCount: Int? = null, ) { - /** The notification list, whichever key the server populated. */ - val items: List get() = data.ifEmpty { notifications } + /** The notification list, whichever key the server populated (`items`, `data`, or `notifications`). */ + val items: List get() = itemsKey.ifEmpty { data.ifEmpty { notifications } } } /** Pagination cursor returned alongside a list of notifications. */ diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/di/NotificationsModule.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/di/NotificationsModule.kt index ae5c8b1..4a9311f 100644 --- a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/di/NotificationsModule.kt +++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/di/NotificationsModule.kt @@ -7,6 +7,10 @@ import com.interlinedlist.android.feature.notifications.data.NotificationsReposi import com.interlinedlist.android.feature.notifications.data.local.NotificationDao import com.interlinedlist.android.feature.notifications.data.local.NotificationsDatabase import com.interlinedlist.android.feature.notifications.data.remote.NotificationsApi +import com.interlinedlist.android.feature.notifications.push.LastSeenNotificationStore +import com.interlinedlist.android.feature.notifications.push.SharedPrefsLastSeenNotificationStore +import com.interlinedlist.android.feature.notifications.push.SystemNotificationPoster +import com.interlinedlist.android.feature.notifications.push.SystemNotificationRaiser import dagger.Binds import dagger.Module import dagger.Provides @@ -26,6 +30,13 @@ abstract class NotificationsRepositoryModule { abstract fun bindNotificationsRepository( impl: DefaultNotificationsRepository, ): NotificationsRepository + + /** Persists the background-poll last-seen marker (see NotificationsPollWorker). */ + @Binds + @Singleton + abstract fun bindLastSeenNotificationStore( + impl: SharedPrefsLastSeenNotificationStore, + ): LastSeenNotificationStore } /** @@ -56,4 +67,11 @@ object NotificationsDataModule { @Provides fun provideNotificationDao(db: NotificationsDatabase): NotificationDao = db.notificationDao() + + /** The system-tray poster used by the background notification poll worker. */ + @Provides + @Singleton + fun provideSystemNotificationRaiser( + @ApplicationContext context: Context, + ): SystemNotificationRaiser = SystemNotificationPoster(context) } diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/LastSeenNotificationStore.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/LastSeenNotificationStore.kt new file mode 100644 index 0000000..01bf1c9 --- /dev/null +++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/LastSeenNotificationStore.kt @@ -0,0 +1,53 @@ +package com.interlinedlist.android.feature.notifications.push + +import android.content.Context +import android.content.SharedPreferences +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Persists the "last-seen" marker for the background notification poll so the + * worker only raises a system notification for items that arrived since the + * previous run — never re-notifying on every poll. + * + * The marker is the newest notification id the poll has already surfaced. It is a + * tiny scalar with no secrets, so it lives in plain SharedPreferences (mirroring + * [com.interlinedlist.android.core.datastore.ThemeSettingsStore]). Modelled behind + * an interface so the worker's selection logic can be unit-tested with an in-memory + * fake, with no Android dependency. + */ +interface LastSeenNotificationStore { + + /** The id of the newest notification already surfaced, or null on first run. */ + fun lastSeenId(): String? + + /** Records [id] as the newest notification already surfaced. */ + fun setLastSeenId(id: String) + + /** Whether any marker has been recorded yet (false = first ever poll). */ + fun hasSeenAny(): Boolean +} + +/** SharedPreferences-backed [LastSeenNotificationStore]. */ +@Singleton +class SharedPrefsLastSeenNotificationStore @Inject constructor( + @ApplicationContext context: Context, +) : LastSeenNotificationStore { + + private val prefs: SharedPreferences = + context.getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE) + + override fun lastSeenId(): String? = prefs.getString(KEY_LAST_SEEN_ID, null) + + override fun setLastSeenId(id: String) { + prefs.edit().putString(KEY_LAST_SEEN_ID, id).apply() + } + + override fun hasSeenAny(): Boolean = prefs.contains(KEY_LAST_SEEN_ID) + + companion object { + private const val PREFS_FILE = "il_notifications_poll.prefs" + private const val KEY_LAST_SEEN_ID = "last_seen_notification_id" + } +} diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/NewNotificationsSelector.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/NewNotificationsSelector.kt new file mode 100644 index 0000000..aee2975 --- /dev/null +++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/NewNotificationsSelector.kt @@ -0,0 +1,68 @@ +package com.interlinedlist.android.feature.notifications.push + +import com.interlinedlist.android.feature.notifications.domain.Notification + +/** + * Pure logic that decides which notifications are NEW relative to the last-seen + * marker, and what the new marker should be. No Android or IO dependencies, so it + * is fully unit-testable. + * + * Input is the API's newest-first page (index 0 is the most recent). Behaviour: + * - The newest id in the page becomes the [SelectionResult.newLastSeenId] to persist. + * - When a [lastSeenId] is present, everything strictly newer than it (i.e. every + * item preceding the matching id in the newest-first list) is NEW. + * - When [lastSeenId] is not found in the page (it fell off the end, or the list was + * cleared server-side), we conservatively treat the whole page as NEW rather than + * guessing — the cap in the poster keeps this from spamming. + * - On the very FIRST poll ([hasSeenAny] == false) we surface nothing and simply + * record the newest id, so logging in / installing never dumps the entire backlog + * into the tray. + */ +object NewNotificationsSelector { + + /** + * @param notifications the newest-first page from `GET /api/notifications`. + * @param lastSeenId the previously-recorded newest id (null on first run). + * @param hasSeenAny whether any marker has ever been recorded. + */ + fun select( + notifications: List, + lastSeenId: String?, + hasSeenAny: Boolean, + ): SelectionResult { + if (notifications.isEmpty()) { + // Nothing to surface; keep the existing marker untouched. + return SelectionResult(newNotifications = emptyList(), newLastSeenId = lastSeenId) + } + + val newestId = notifications.first().id + + // First ever poll: adopt the newest id as a baseline, surface nothing. + if (!hasSeenAny || lastSeenId == null) { + return SelectionResult(newNotifications = emptyList(), newLastSeenId = newestId) + } + + // Already up to date. + if (newestId == lastSeenId) { + return SelectionResult(newNotifications = emptyList(), newLastSeenId = newestId) + } + + val markerIndex = notifications.indexOfFirst { it.id == lastSeenId } + val newItems = if (markerIndex >= 0) { + notifications.subList(0, markerIndex).toList() + } else { + // Marker not in this page — treat the whole page as new (bounded by the cap). + notifications.toList() + } + + return SelectionResult(newNotifications = newItems, newLastSeenId = newestId) + } +} + +/** Outcome of [NewNotificationsSelector.select]. */ +data class SelectionResult( + /** Notifications that arrived since the last poll, newest-first. */ + val newNotifications: List, + /** The marker to persist as the new last-seen id (null only when there is nothing to record). */ + val newLastSeenId: String?, +) diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationCategory.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationCategory.kt new file mode 100644 index 0000000..d81ef51 --- /dev/null +++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationCategory.kt @@ -0,0 +1,73 @@ +package com.interlinedlist.android.feature.notifications.push + +import com.interlinedlist.android.feature.notifications.domain.NotificationType + +/** + * A system-notification channel category. Each maps a coarse [NotificationType] to a + * user-facing Android notification channel (registered on O+) and to the notification + * -preference event keys that gate its `push` delivery. + * + * The channel ids are stable strings persisted by the OS, so they must not change once + * shipped. [preferenceKeys] lists the candidate event keys the backend uses for this + * category (`GET /api/user/notification-preferences`); the first one present in the + * fetched preferences wins when deciding whether push is enabled. + */ +enum class NotificationCategory( + val channelId: String, + val channelName: String, + val channelDescription: String, + val preferenceKeys: List, +) { + MESSAGES( + channelId = "il_messages", + channelName = "Messages", + channelDescription = "New messages and direct messages", + preferenceKeys = listOf("message", "messages", "dm", "direct_message"), + ), + FOLLOWS( + channelId = "il_follows", + channelName = "Follows", + channelDescription = "New followers and follow requests", + preferenceKeys = listOf("follow", "follower", "new_follower", "follow_request"), + ), + DIGS( + channelId = "il_digs", + channelName = "Digs", + channelDescription = "When someone digs your content", + preferenceKeys = listOf("dig", "like", "favorite"), + ), + MENTIONS( + channelId = "il_mentions", + channelName = "Mentions", + channelDescription = "When someone mentions you", + preferenceKeys = listOf("mention", "tag"), + ), + REPLIES( + channelId = "il_replies", + channelName = "Replies", + channelDescription = "Replies and comments on your content", + preferenceKeys = listOf("reply", "comment"), + ), + OTHER( + channelId = "il_other", + channelName = "Other", + channelDescription = "Shared lists, system and account notifications", + preferenceKeys = listOf("list", "list_share", "system", "account"), + ); + + companion object { + + /** Maps a coarse [NotificationType] to the category whose channel it belongs to. */ + fun forType(type: NotificationType): NotificationCategory = when (type) { + NotificationType.MESSAGE -> MESSAGES + NotificationType.FOLLOW -> FOLLOWS + NotificationType.LIKE -> DIGS + NotificationType.MENTION -> MENTIONS + NotificationType.REPLY -> REPLIES + NotificationType.LIST_SHARE, + NotificationType.SYSTEM, + NotificationType.OTHER, + -> OTHER + } + } +} diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationDeepLink.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationDeepLink.kt new file mode 100644 index 0000000..bca5054 --- /dev/null +++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationDeepLink.kt @@ -0,0 +1,65 @@ +package com.interlinedlist.android.feature.notifications.push + +import com.interlinedlist.android.feature.notifications.domain.Notification +import com.interlinedlist.android.feature.notifications.domain.NotificationTargetKind + +/** + * The intent extras carried by a tapped system notification, and the pure logic that + * turns a [Notification]'s target into a destination the app can route to. + * + * The worker attaches these extras to the [android.app.PendingIntent] that opens + * [MainActivity]; the app reads them and navigates. Keeping the keys and the + * type→destination mapping here means the module owns the contract and it stays + * unit-testable without any Android UI. + */ +object NotificationDeepLink { + + /** Extra flagging that the launch originated from a notification tap. */ + const val EXTRA_FROM_NOTIFICATION = "il.extra.from_notification" + + /** Extra naming the destination the app should route to (see [Destination]). */ + const val EXTRA_DESTINATION = "il.extra.notification_destination" + + /** Extra carrying the target entity id, when the destination needs one. */ + const val EXTRA_TARGET_ID = "il.extra.notification_target_id" + + /** + * Where a tapped notification should land. The app maps these to concrete nav + * routes; anything without a resolvable target falls back to [NOTIFICATIONS]. + */ + enum class Destination { + /** Open a specific message thread ([EXTRA_TARGET_ID] = message id). */ + MESSAGE, + + /** Open a user's profile ([EXTRA_TARGET_ID] = username/id). */ + USER, + + /** Open a specific list ([EXTRA_TARGET_ID] = list id). */ + LIST, + + /** Open the in-app notifications feed (the safe fallback). */ + NOTIFICATIONS, + } + + /** + * Resolves the destination for [notification] from its target. Falls back to + * [Destination.NOTIFICATIONS] when there is no usable, specific target. + */ + fun destinationFor(notification: Notification): Destination { + val target = notification.target ?: return Destination.NOTIFICATIONS + if (target.id.isBlank()) return Destination.NOTIFICATIONS + return when (target.kind) { + NotificationTargetKind.MESSAGE -> Destination.MESSAGE + NotificationTargetKind.USER -> Destination.USER + NotificationTargetKind.LIST -> Destination.LIST + NotificationTargetKind.OTHER -> Destination.NOTIFICATIONS + } + } + + /** The target id to carry for [notification], or null when routing to the feed. */ + fun targetIdFor(notification: Notification): String? = + when (destinationFor(notification)) { + Destination.NOTIFICATIONS -> null + else -> notification.target?.id?.takeIf { it.isNotBlank() } + } +} diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationPollProcessor.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationPollProcessor.kt new file mode 100644 index 0000000..99ff4cb --- /dev/null +++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationPollProcessor.kt @@ -0,0 +1,50 @@ +package com.interlinedlist.android.feature.notifications.push + +import com.interlinedlist.android.feature.notifications.domain.Notification +import com.interlinedlist.android.feature.notifications.domain.NotificationPreference + +/** + * Pure orchestration of one poll pass, with no Android/IO dependencies so it is fully + * unit-testable. Given the freshly-fetched notifications, the current preferences, and + * the persisted last-seen state, it decides: + * - which notifications must be raised in the tray (NEW since last-seen AND push-enabled), and + * - the marker to persist next. + * + * The worker feeds it inputs it gathered over the network / store and applies its + * [Outcome] (post + persist). This keeps the "what to do" logic separate from the + * "how to do it" side-effects (SRP). + */ +object NotificationPollProcessor { + + /** + * @param fetched newest-first page from `GET /api/notifications`. + * @param preferences current per-event preferences (empty if the fetch failed). + * @param lastSeenId the previously-recorded newest id (null on first run). + * @param hasSeenAny whether any marker has ever been recorded. + */ + fun process( + fetched: List, + preferences: List, + lastSeenId: String?, + hasSeenAny: Boolean, + ): Outcome { + val selection = NewNotificationsSelector.select( + notifications = fetched, + lastSeenId = lastSeenId, + hasSeenAny = hasSeenAny, + ) + val toPost = NotificationPushFilter(preferences).filter(selection.newNotifications) + return Outcome( + toPost = toPost, + newLastSeenId = selection.newLastSeenId, + ) + } + + /** The actions the worker should apply after a poll pass. */ + data class Outcome( + /** Notifications to raise in the system tray (newest-first). */ + val toPost: List, + /** The marker to persist, or null when there is nothing to record. */ + val newLastSeenId: String?, + ) +} diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationPollRunner.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationPollRunner.kt new file mode 100644 index 0000000..654b2c1 --- /dev/null +++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationPollRunner.kt @@ -0,0 +1,61 @@ +package com.interlinedlist.android.feature.notifications.push + +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.notifications.data.NotificationPreferencesRepository +import com.interlinedlist.android.feature.notifications.data.NotificationsRepository +import javax.inject.Inject + +/** + * Runs one background-poll pass: fetch the latest notifications, resolve the recipient's + * preferences, decide what is NEW and push-enabled (via the pure [NotificationPollProcessor]), + * raise the tray notifications, and advance the persisted last-seen marker. + * + * Holding this outside the `@HiltWorker` keeps it free of the Android `CoroutineWorker` + * superclass, so it is fully unit-testable; [NotificationsPollWorker] is a thin adapter + * that maps the [Result] onto WorkManager's outcome. + */ +class NotificationPollRunner @Inject constructor( + private val notificationsRepository: NotificationsRepository, + private val preferencesRepository: NotificationPreferencesRepository, + private val lastSeenStore: LastSeenNotificationStore, + private val raiser: SystemNotificationRaiser, +) { + + /** The outcome of a poll, mapped by the worker onto WorkManager's Result. */ + enum class Result { + /** Completed (whether or not anything was posted). */ + SUCCESS, + + /** Transient failure (fetch failed) — the worker should retry with backoff. */ + RETRY, + } + + suspend fun run(): Result { + val fetched = when (val result = notificationsRepository.fetchLatest()) { + is ApiResult.Success -> result.data + is ApiResult.Failure -> return Result.RETRY + } + + // Preferences are best-effort: on failure, fall back to no preferences, which + // the push filter interprets as default-notify. + val preferences = when (val prefs = preferencesRepository.getPreferences()) { + is ApiResult.Success -> prefs.data + is ApiResult.Failure -> emptyList() + } + + val outcome = NotificationPollProcessor.process( + fetched = fetched, + preferences = preferences, + lastSeenId = lastSeenStore.lastSeenId(), + hasSeenAny = lastSeenStore.hasSeenAny(), + ) + + if (outcome.toPost.isNotEmpty()) { + raiser.post(outcome.toPost) + } + // Advance the marker after posting, so a crash before posting doesn't skip items. + outcome.newLastSeenId?.let(lastSeenStore::setLastSeenId) + + return Result.SUCCESS + } +} diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationPushFilter.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationPushFilter.kt new file mode 100644 index 0000000..8dc0ae4 --- /dev/null +++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationPushFilter.kt @@ -0,0 +1,53 @@ +package com.interlinedlist.android.feature.notifications.push + +import com.interlinedlist.android.feature.notifications.domain.Notification +import com.interlinedlist.android.feature.notifications.domain.NotificationChannel +import com.interlinedlist.android.feature.notifications.domain.NotificationPreference + +/** + * Pure decision logic that gates which notifications may be raised as a system + * (push-tray) notification, based on the recipient's per-event `push` preference. + * No Android or IO dependencies, so it is fully unit-testable. + * + * Mapping is best-effort: a notification's [NotificationType][com.interlinedlist.android.feature.notifications.domain.NotificationType] + * is mapped to a [NotificationCategory], whose candidate [NotificationCategory.preferenceKeys] + * are matched (case-insensitively) against the fetched preference event keys. The first + * matching preference's [NotificationChannel.PUSH] state decides delivery. + * + * DEFAULT-NOTIFY: when no preference maps to the notification (unknown/unmodelled + * event, or preferences could not be fetched), we err on the side of notifying — a + * missed alert is worse than an unexpected one, and the recipient can still mute the + * channel from system settings. + */ +class NotificationPushFilter( + preferences: List, +) { + + /** Preferences indexed by their lower-cased key for O(1), case-insensitive lookup. */ + private val byKey: Map = + preferences.associateBy { it.key.trim().lowercase() } + + /** + * Whether [notification] should be raised in the system tray. True when the mapped + * `push` preference is enabled, or when nothing maps (default-notify). False only + * when a matching preference explicitly disables `push`. + */ + fun shouldNotify(notification: Notification): Boolean { + val preference = matchPreference(notification) ?: return true // default-notify + // If the event doesn't model a push channel at all, treat it as allowed. + if (NotificationChannel.PUSH !in preference.channels) return true + return preference.isEnabled(NotificationChannel.PUSH) + } + + /** Keeps only the notifications whose `push` channel is enabled (or unmapped). */ + fun filter(notifications: List): List = + notifications.filter(::shouldNotify) + + private fun matchPreference(notification: Notification): NotificationPreference? { + val category = NotificationCategory.forType(notification.type) + for (candidate in category.preferenceKeys) { + byKey[candidate.lowercase()]?.let { return it } + } + return null + } +} diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationsPollWorker.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationsPollWorker.kt new file mode 100644 index 0000000..afa0dea --- /dev/null +++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationsPollWorker.kt @@ -0,0 +1,35 @@ +package com.interlinedlist.android.feature.notifications.push + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject + +/** + * Background poll that stands in for FCM push (the product ships no Firebase project). + * A thin `@HiltWorker` adapter: it delegates the actual work to the injectable, + * unit-tested [NotificationPollRunner] and maps its outcome onto WorkManager's [Result]. + * + * The `HiltWorkerFactory` supplied by `InterlinedListApplication` (Configuration.Provider) + * constructs this with its dependencies — no extra bootstrap needed. + */ +@HiltWorker +class NotificationsPollWorker @AssistedInject constructor( + @Assisted appContext: Context, + @Assisted params: WorkerParameters, + private val runner: NotificationPollRunner, +) : CoroutineWorker(appContext, params) { + + override suspend fun doWork(): Result = when (runner.run()) { + NotificationPollRunner.Result.SUCCESS -> Result.success() + NotificationPollRunner.Result.RETRY -> Result.retry() + } + + companion object { + /** Unique names for the scheduled work (see [NotificationsSyncScheduler]). */ + const val PERIODIC_WORK_NAME = "notifications-poll-periodic" + const val ONE_SHOT_WORK_NAME = "notifications-poll-oneshot" + } +} diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationsSyncScheduler.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationsSyncScheduler.kt new file mode 100644 index 0000000..ec6c7ed --- /dev/null +++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationsSyncScheduler.kt @@ -0,0 +1,70 @@ +package com.interlinedlist.android.feature.notifications.push + +import android.content.Context +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import java.time.Duration + +/** + * Schedules the background notification poll. Keep this the single place that enqueues + * [NotificationsPollWorker] so the unique-work names and constraints stay consistent + * (mirrors `DocumentsSyncScheduler`). + * + * Wiring (call from the app after sign-in): + * ``` + * NotificationsSyncScheduler.schedulePeriodic(context) // near-real-time tray poll + * NotificationsSyncScheduler.syncNow(context) // e.g. right after login + * NotificationsSyncScheduler.cancelAll(context) // on sign-out + * ``` + */ +object NotificationsSyncScheduler { + + /** WorkManager's minimum periodic interval; also our chosen cadence. */ + val MIN_INTERVAL: Duration = Duration.ofMinutes(15) + + private val NETWORK_CONSTRAINTS = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + /** + * Periodic background poll. WorkManager's floor is 15 minutes, so any shorter + * [interval] is clamped up to [MIN_INTERVAL]. + */ + fun schedulePeriodic(context: Context, interval: Duration = MIN_INTERVAL) { + val effective = if (interval < MIN_INTERVAL) MIN_INTERVAL else interval + val request = PeriodicWorkRequestBuilder(effective) + .setConstraints(NETWORK_CONSTRAINTS) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, Duration.ofSeconds(30)) + .build() + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + NotificationsPollWorker.PERIODIC_WORK_NAME, + ExistingPeriodicWorkPolicy.KEEP, + request, + ) + } + + /** One-shot poll now — e.g. immediately after login so the marker seeds fast. */ + fun syncNow(context: Context) { + val request = OneTimeWorkRequestBuilder() + .setConstraints(NETWORK_CONSTRAINTS) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, Duration.ofSeconds(15)) + .build() + WorkManager.getInstance(context).enqueueUniqueWork( + NotificationsPollWorker.ONE_SHOT_WORK_NAME, + ExistingWorkPolicy.REPLACE, + request, + ) + } + + /** Cancels all scheduled notification polling (e.g. on sign-out). */ + fun cancelAll(context: Context) { + WorkManager.getInstance(context).cancelUniqueWork(NotificationsPollWorker.PERIODIC_WORK_NAME) + WorkManager.getInstance(context).cancelUniqueWork(NotificationsPollWorker.ONE_SHOT_WORK_NAME) + } +} diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/SystemNotificationChannels.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/SystemNotificationChannels.kt new file mode 100644 index 0000000..902bf77 --- /dev/null +++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/SystemNotificationChannels.kt @@ -0,0 +1,36 @@ +package com.interlinedlist.android.feature.notifications.push + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.os.Build +import androidx.core.content.getSystemService + +/** + * Registers the app's system notification channels (one per [NotificationCategory]) + * on Android O+. Idempotent — creating a channel that already exists is a no-op, and + * pre-O is skipped entirely (channels don't exist there). Safe to call at every app + * start. + * + * The decision of WHICH channels to register (their ids/names) is the pure + * [NotificationCategory] enum, which is unit-tested; this helper is the thin Android + * side-effect that installs them. + */ +object SystemNotificationChannels { + + /** Creates every category channel. No-op below Android O. */ + fun ensureRegistered(context: Context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val manager = context.getSystemService() ?: return + NotificationCategory.entries.forEach { category -> + val channel = NotificationChannel( + category.channelId, + category.channelName, + NotificationManager.IMPORTANCE_DEFAULT, + ).apply { + description = category.channelDescription + } + manager.createNotificationChannel(channel) + } + } +} diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/SystemNotificationPoster.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/SystemNotificationPoster.kt new file mode 100644 index 0000000..b52c5ae --- /dev/null +++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/SystemNotificationPoster.kt @@ -0,0 +1,153 @@ +package com.interlinedlist.android.feature.notifications.push + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.interlinedlist.android.feature.notifications.R +import com.interlinedlist.android.feature.notifications.domain.Notification + +/** + * Raises system-tray notifications for a batch of new notifications. Abstracted behind + * an interface (DIP) so the worker can be unit-tested with a fake, and the real Android + * side-effects live only in [SystemNotificationPoster]. + */ +interface SystemNotificationRaiser { + /** Raises tray notifications for [items] (newest-first, already push-filtered). */ + fun post(items: List) +} + +/** + * Posts system-tray notifications for a batch of NEW notifications. The batch is + * capped so a large backlog can't spam the tray: at most [MAX_INDIVIDUAL] individual + * items are shown, and when there are more, only a single summary is posted. + * + * Each notification's tap opens the app's launcher activity (resolved via the package + * manager, so this module needs no compile-time reference to `MainActivity`) carrying + * the [NotificationDeepLink] extras the app reads to route. + * + * Grouping: individual items share a [GROUP_KEY] and a summary notification anchors the + * group so the shade collapses them tidily on Android N+. + */ +class SystemNotificationPoster( + private val context: Context, +) : SystemNotificationRaiser { + + /** + * Posts [items] (newest-first, already push-filtered). No-op when the list is empty + * or the user has notifications disabled at the OS level. + */ + override fun post(items: List) { + if (items.isEmpty()) return + val manager = NotificationManagerCompat.from(context) + if (!manager.areNotificationsEnabled()) return + + if (items.size > MAX_INDIVIDUAL) { + postSummaryOnly(manager, items) + return + } + + items.forEach { notification -> + manager.safeNotify(notification.id.hashCode(), buildIndividual(notification)) + } + if (items.size > 1) { + manager.safeNotify(SUMMARY_ID, buildGroupSummary(items)) + } + } + + private fun postSummaryOnly(manager: NotificationManagerCompat, items: List) { + val category = NotificationCategory.OTHER + val summary = NotificationCompat.Builder(context, category.channelId) + .setSmallIcon(R.drawable.il_notification_icon) + .setContentTitle("$COUNT_TITLE_PREFIX ${items.size} new notifications") + .setContentText(items.first().subject) + .setAutoCancel(true) + .setContentIntent(feedPendingIntent()) + .build() + manager.safeNotify(SUMMARY_ID, summary) + } + + private fun buildIndividual(notification: Notification): android.app.Notification { + val category = NotificationCategory.forType(notification.type) + val title = notification.actorLabel?.let { "$it" } ?: category.channelName + return NotificationCompat.Builder(context, category.channelId) + .setSmallIcon(R.drawable.il_notification_icon) + .setContentTitle(notification.subject.ifBlank { title }) + .apply { notification.body?.let { setContentText(it) } } + .setAutoCancel(true) + .setGroup(GROUP_KEY) + .setContentIntent(pendingIntentFor(notification)) + .build() + } + + private fun buildGroupSummary(items: List): android.app.Notification = + NotificationCompat.Builder(context, NotificationCategory.OTHER.channelId) + .setSmallIcon(R.drawable.il_notification_icon) + .setContentTitle("$COUNT_TITLE_PREFIX ${items.size} new notifications") + .setContentText(items.first().subject) + .setGroup(GROUP_KEY) + .setGroupSummary(true) + .setAutoCancel(true) + .setContentIntent(feedPendingIntent()) + .build() + + // --- intents ----------------------------------------------------------- + + /** Launch intent → the app, tagged so the app routes to the specific target. */ + private fun pendingIntentFor(notification: Notification): PendingIntent { + val destination = NotificationDeepLink.destinationFor(notification) + val intent = launchIntent().apply { + putExtra(NotificationDeepLink.EXTRA_FROM_NOTIFICATION, true) + putExtra(NotificationDeepLink.EXTRA_DESTINATION, destination.name) + NotificationDeepLink.targetIdFor(notification)?.let { + putExtra(NotificationDeepLink.EXTRA_TARGET_ID, it) + } + } + return activityPendingIntent(notification.id.hashCode(), intent) + } + + /** Launch intent → the app, routed to the in-app notifications feed (fallback). */ + private fun feedPendingIntent(): PendingIntent { + val intent = launchIntent().apply { + putExtra(NotificationDeepLink.EXTRA_FROM_NOTIFICATION, true) + putExtra( + NotificationDeepLink.EXTRA_DESTINATION, + NotificationDeepLink.Destination.NOTIFICATIONS.name, + ) + } + return activityPendingIntent(SUMMARY_ID, intent) + } + + private fun launchIntent(): Intent = + context.packageManager.getLaunchIntentForPackage(context.packageName) + ?.apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP } + ?: Intent(Intent.ACTION_MAIN).apply { setPackage(context.packageName) } + + private fun activityPendingIntent(requestCode: Int, intent: Intent): PendingIntent = + PendingIntent.getActivity( + context, + requestCode, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + private fun NotificationManagerCompat.safeNotify(id: Int, notification: android.app.Notification) { + // areNotificationsEnabled() is checked up front; guard the individual post too + // so a revoked POST_NOTIFICATIONS permission never throws on the worker thread. + runCatching { notify(id, notification) } + } + + companion object { + /** Max individual notifications before collapsing to a single summary. */ + const val MAX_INDIVIDUAL = 5 + + /** Shared group key so the shade collapses our notifications together. */ + const val GROUP_KEY = "il.notifications.group" + + /** Stable id for the group-summary / overflow notification. */ + const val SUMMARY_ID = 424242 + + private const val COUNT_TITLE_PREFIX = "InterlinedList:" + } +} diff --git a/feature/notifications/src/main/res/drawable/il_notification_icon.xml b/feature/notifications/src/main/res/drawable/il_notification_icon.xml new file mode 100644 index 0000000..611d9fa --- /dev/null +++ b/feature/notifications/src/main/res/drawable/il_notification_icon.xml @@ -0,0 +1,15 @@ + + + + diff --git a/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/data/remote/dto/NotificationsResponseTest.kt b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/data/remote/dto/NotificationsResponseTest.kt index aa2bd6d..bb8713f 100644 --- a/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/data/remote/dto/NotificationsResponseTest.kt +++ b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/data/remote/dto/NotificationsResponseTest.kt @@ -28,6 +28,19 @@ class NotificationsResponseTest { assertThat(response.items.map { it.id }).containsExactly("a") } + @Test + fun `reads the list from the live items key`() { + // The production API returns `{ "unreadCount": N, "items": [...] }`. Regression + // guard: this key was previously unmapped, silently emptying the notifications + // feed and the background push poll. + val response = json.decodeFromString( + NotificationsResponse.serializer(), + """{ "unreadCount": 3, "items": [ { "id": "x" }, { "id": "y" } ] }""", + ) + assertThat(response.items.map { it.id }).containsExactly("x", "y").inOrder() + assertThat(response.unreadCount).isEqualTo(3) + } + @Test fun `an empty body decodes with sane defaults`() { val response = json.decodeFromString(NotificationsResponse.serializer(), "{}") diff --git a/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/push/FakeLastSeenNotificationStore.kt b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/push/FakeLastSeenNotificationStore.kt new file mode 100644 index 0000000..c919193 --- /dev/null +++ b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/push/FakeLastSeenNotificationStore.kt @@ -0,0 +1,19 @@ +package com.interlinedlist.android.feature.notifications.push + +/** In-memory [LastSeenNotificationStore] for unit tests (no Android dependency). */ +class FakeLastSeenNotificationStore( + initial: String? = null, +) : LastSeenNotificationStore { + + private var lastSeen: String? = initial + private var seenAny: Boolean = initial != null + + override fun lastSeenId(): String? = lastSeen + + override fun setLastSeenId(id: String) { + lastSeen = id + seenAny = true + } + + override fun hasSeenAny(): Boolean = seenAny +} diff --git a/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/push/NewNotificationsSelectorTest.kt b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/push/NewNotificationsSelectorTest.kt new file mode 100644 index 0000000..14cfa11 --- /dev/null +++ b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/push/NewNotificationsSelectorTest.kt @@ -0,0 +1,88 @@ +package com.interlinedlist.android.feature.notifications.push + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.feature.notifications.domain.Notification +import com.interlinedlist.android.feature.notifications.domain.NotificationType +import org.junit.Test + +class NewNotificationsSelectorTest { + + private fun notif(id: String) = Notification( + id = id, + type = NotificationType.FOLLOW, + actor = null, + subject = "s-$id", + body = null, + createdAt = null, + read = false, + target = null, + ) + + @Test + fun `first ever poll surfaces nothing and adopts the newest id as baseline`() { + val page = listOf(notif("3"), notif("2"), notif("1")) + + val result = NewNotificationsSelector.select( + notifications = page, + lastSeenId = null, + hasSeenAny = false, + ) + + assertThat(result.newNotifications).isEmpty() + assertThat(result.newLastSeenId).isEqualTo("3") + } + + @Test + fun `only items newer than the marker are new and marker advances`() { + val page = listOf(notif("5"), notif("4"), notif("3"), notif("2")) + + val result = NewNotificationsSelector.select( + notifications = page, + lastSeenId = "3", + hasSeenAny = true, + ) + + assertThat(result.newNotifications.map { it.id }).containsExactly("5", "4").inOrder() + assertThat(result.newLastSeenId).isEqualTo("5") + } + + @Test + fun `no new items when the newest already equals the marker`() { + val page = listOf(notif("9"), notif("8")) + + val result = NewNotificationsSelector.select( + notifications = page, + lastSeenId = "9", + hasSeenAny = true, + ) + + assertThat(result.newNotifications).isEmpty() + assertThat(result.newLastSeenId).isEqualTo("9") + } + + @Test + fun `marker missing from the page treats the whole page as new`() { + val page = listOf(notif("7"), notif("6")) + + val result = NewNotificationsSelector.select( + notifications = page, + lastSeenId = "1", // fell off the page + hasSeenAny = true, + ) + + assertThat(result.newNotifications.map { it.id }).containsExactly("7", "6").inOrder() + assertThat(result.newLastSeenId).isEqualTo("7") + } + + @Test + fun `empty page is a no-op and keeps the existing marker`() { + val result = NewNotificationsSelector.select( + notifications = emptyList(), + lastSeenId = "4", + hasSeenAny = true, + ) + + assertThat(result.newNotifications).isEmpty() + assertThat(result.newLastSeenId).isEqualTo("4") + } +} diff --git a/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationCategoryTest.kt b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationCategoryTest.kt new file mode 100644 index 0000000..205f7db --- /dev/null +++ b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationCategoryTest.kt @@ -0,0 +1,48 @@ +package com.interlinedlist.android.feature.notifications.push + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.feature.notifications.domain.NotificationType +import org.junit.Test + +class NotificationCategoryTest { + + @Test + fun `every notification type maps to a category`() { + // Exhaustive: no type should be unmapped. + NotificationType.entries.forEach { type -> + val category = NotificationCategory.forType(type) + assertThat(category).isNotNull() + } + } + + @Test + fun `core types map to their expected categories`() { + assertThat(NotificationCategory.forType(NotificationType.MESSAGE)) + .isEqualTo(NotificationCategory.MESSAGES) + assertThat(NotificationCategory.forType(NotificationType.FOLLOW)) + .isEqualTo(NotificationCategory.FOLLOWS) + assertThat(NotificationCategory.forType(NotificationType.LIKE)) + .isEqualTo(NotificationCategory.DIGS) + assertThat(NotificationCategory.forType(NotificationType.MENTION)) + .isEqualTo(NotificationCategory.MENTIONS) + assertThat(NotificationCategory.forType(NotificationType.REPLY)) + .isEqualTo(NotificationCategory.REPLIES) + } + + @Test + fun `list share, system and unknown fall back to OTHER`() { + assertThat(NotificationCategory.forType(NotificationType.LIST_SHARE)) + .isEqualTo(NotificationCategory.OTHER) + assertThat(NotificationCategory.forType(NotificationType.SYSTEM)) + .isEqualTo(NotificationCategory.OTHER) + assertThat(NotificationCategory.forType(NotificationType.OTHER)) + .isEqualTo(NotificationCategory.OTHER) + } + + @Test + fun `channel ids are unique and non-blank`() { + val ids = NotificationCategory.entries.map { it.channelId } + assertThat(ids).containsNoDuplicates() + assertThat(ids.none { it.isBlank() }).isTrue() + } +} diff --git a/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationDeepLinkTest.kt b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationDeepLinkTest.kt new file mode 100644 index 0000000..f14a1b5 --- /dev/null +++ b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationDeepLinkTest.kt @@ -0,0 +1,75 @@ +package com.interlinedlist.android.feature.notifications.push + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.feature.notifications.domain.Notification +import com.interlinedlist.android.feature.notifications.domain.NotificationTarget +import com.interlinedlist.android.feature.notifications.domain.NotificationTargetKind +import com.interlinedlist.android.feature.notifications.domain.NotificationType +import org.junit.Test + +class NotificationDeepLinkTest { + + private fun notif(target: NotificationTarget?) = Notification( + id = "1", + type = NotificationType.OTHER, + actor = null, + subject = "s", + body = null, + createdAt = null, + read = false, + target = target, + ) + + @Test + fun `message target routes to MESSAGE with its id`() { + val n = notif(NotificationTarget(NotificationTargetKind.MESSAGE, "m1")) + + assertThat(NotificationDeepLink.destinationFor(n)) + .isEqualTo(NotificationDeepLink.Destination.MESSAGE) + assertThat(NotificationDeepLink.targetIdFor(n)).isEqualTo("m1") + } + + @Test + fun `user target routes to USER`() { + val n = notif(NotificationTarget(NotificationTargetKind.USER, "amy")) + + assertThat(NotificationDeepLink.destinationFor(n)) + .isEqualTo(NotificationDeepLink.Destination.USER) + assertThat(NotificationDeepLink.targetIdFor(n)).isEqualTo("amy") + } + + @Test + fun `list target routes to LIST`() { + val n = notif(NotificationTarget(NotificationTargetKind.LIST, "l9")) + + assertThat(NotificationDeepLink.destinationFor(n)) + .isEqualTo(NotificationDeepLink.Destination.LIST) + assertThat(NotificationDeepLink.targetIdFor(n)).isEqualTo("l9") + } + + @Test + fun `null target falls back to the notifications feed`() { + val n = notif(null) + + assertThat(NotificationDeepLink.destinationFor(n)) + .isEqualTo(NotificationDeepLink.Destination.NOTIFICATIONS) + assertThat(NotificationDeepLink.targetIdFor(n)).isNull() + } + + @Test + fun `OTHER target kind falls back to the notifications feed`() { + val n = notif(NotificationTarget(NotificationTargetKind.OTHER, "x")) + + assertThat(NotificationDeepLink.destinationFor(n)) + .isEqualTo(NotificationDeepLink.Destination.NOTIFICATIONS) + assertThat(NotificationDeepLink.targetIdFor(n)).isNull() + } + + @Test + fun `blank target id falls back to the notifications feed`() { + val n = notif(NotificationTarget(NotificationTargetKind.MESSAGE, " ")) + + assertThat(NotificationDeepLink.destinationFor(n)) + .isEqualTo(NotificationDeepLink.Destination.NOTIFICATIONS) + } +} diff --git a/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationPollProcessorTest.kt b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationPollProcessorTest.kt new file mode 100644 index 0000000..b15213f --- /dev/null +++ b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationPollProcessorTest.kt @@ -0,0 +1,102 @@ +package com.interlinedlist.android.feature.notifications.push + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.feature.notifications.domain.Notification +import com.interlinedlist.android.feature.notifications.domain.NotificationChannel +import com.interlinedlist.android.feature.notifications.domain.NotificationPreference +import com.interlinedlist.android.feature.notifications.domain.NotificationType +import org.junit.Test + +class NotificationPollProcessorTest { + + private fun notif(id: String, type: NotificationType) = Notification( + id = id, + type = type, + actor = null, + subject = "s-$id", + body = null, + createdAt = null, + read = false, + target = null, + ) + + private fun pref(key: String, push: Boolean) = NotificationPreference( + key = key, + label = key, + description = "", + channels = mapOf(NotificationChannel.PUSH to push), + ) + + @Test + fun `first poll posts nothing and seeds the marker`() { + val outcome = NotificationPollProcessor.process( + fetched = listOf(notif("3", NotificationType.FOLLOW), notif("2", NotificationType.FOLLOW)), + preferences = emptyList(), + lastSeenId = null, + hasSeenAny = false, + ) + + assertThat(outcome.toPost).isEmpty() + assertThat(outcome.newLastSeenId).isEqualTo("3") + } + + @Test + fun `only new items are posted and the marker advances`() { + val outcome = NotificationPollProcessor.process( + fetched = listOf( + notif("5", NotificationType.FOLLOW), + notif("4", NotificationType.MENTION), + notif("3", NotificationType.FOLLOW), + ), + preferences = emptyList(), + lastSeenId = "3", + hasSeenAny = true, + ) + + assertThat(outcome.toPost.map { it.id }).containsExactly("5", "4").inOrder() + assertThat(outcome.newLastSeenId).isEqualTo("5") + } + + @Test + fun `push-disabled events are filtered out of the post list`() { + val outcome = NotificationPollProcessor.process( + fetched = listOf( + notif("5", NotificationType.FOLLOW), // follow push disabled -> dropped + notif("4", NotificationType.MENTION), // no pref -> default notify + ), + preferences = listOf(pref("follow", push = false)), + lastSeenId = "3", + hasSeenAny = true, + ) + + assertThat(outcome.toPost.map { it.id }).containsExactly("4") + // Marker still advances to the true newest, even though "5" wasn't posted. + assertThat(outcome.newLastSeenId).isEqualTo("5") + } + + @Test + fun `no new items is a no-op post but keeps the marker current`() { + val outcome = NotificationPollProcessor.process( + fetched = listOf(notif("9", NotificationType.FOLLOW)), + preferences = emptyList(), + lastSeenId = "9", + hasSeenAny = true, + ) + + assertThat(outcome.toPost).isEmpty() + assertThat(outcome.newLastSeenId).isEqualTo("9") + } + + @Test + fun `empty fetch posts nothing`() { + val outcome = NotificationPollProcessor.process( + fetched = emptyList(), + preferences = emptyList(), + lastSeenId = "4", + hasSeenAny = true, + ) + + assertThat(outcome.toPost).isEmpty() + assertThat(outcome.newLastSeenId).isEqualTo("4") + } +} diff --git a/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationPollRunnerTest.kt b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationPollRunnerTest.kt new file mode 100644 index 0000000..f43c65c --- /dev/null +++ b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationPollRunnerTest.kt @@ -0,0 +1,205 @@ +package com.interlinedlist.android.feature.notifications.push + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.notifications.data.DefaultNotificationsRepository +import com.interlinedlist.android.feature.notifications.data.FakeNotificationDao +import com.interlinedlist.android.feature.notifications.data.NotificationPreferencesRepository +import com.interlinedlist.android.feature.notifications.data.TestDispatcherProvider +import com.interlinedlist.android.feature.notifications.data.remote.NotificationsApi +import com.interlinedlist.android.feature.notifications.domain.NotificationChannel +import com.interlinedlist.android.feature.notifications.domain.NotificationPreference +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Before +import org.junit.Test +import retrofit2.Retrofit + +@OptIn(ExperimentalCoroutinesApi::class) +class NotificationPollRunnerTest { + + private val dispatcher = StandardTestDispatcher() + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false } + + private lateinit var server: MockWebServer + private lateinit var api: NotificationsApi + private lateinit var dao: FakeNotificationDao + + @Before + fun setUp() { + server = MockWebServer() + server.start() + val contentType = "application/json".toMediaType() + api = Retrofit.Builder() + .baseUrl(server.url("/")) + .addConverterFactory(json.asConverterFactory(contentType)) + .build() + .create(NotificationsApi::class.java) + dao = FakeNotificationDao() + } + + @After + fun tearDown() = server.shutdown() + + private fun notificationsRepo() = DefaultNotificationsRepository( + api = api, + notificationDao = dao, + json = json, + dispatchers = TestDispatcherProvider(dispatcher), + ) + + /** Preferences repo whose GET result is fixed. */ + private fun prefsRepo( + result: ApiResult> = ApiResult.Success(emptyList()), + ): NotificationPreferencesRepository = object : NotificationPreferencesRepository { + override suspend fun getPreferences() = result + override suspend fun updatePreference(preference: NotificationPreference) = + ApiResult.Success(Unit) + } + + private fun runner( + store: LastSeenNotificationStore, + raiser: SystemNotificationRaiser, + prefs: NotificationPreferencesRepository = prefsRepo(), + ) = NotificationPollRunner( + notificationsRepository = notificationsRepo(), + preferencesRepository = prefs, + lastSeenStore = store, + raiser = raiser, + ) + + private fun enqueue(body: String, code: Int = 200) { + server.enqueue(MockResponse().setResponseCode(code).setBody(body)) + } + + @Test + fun `first poll posts nothing and seeds the marker`() = runTest(dispatcher) { + enqueue( + """{ "data": [ { "id": "3", "type": "follow", "subject": "c" }, + { "id": "2", "type": "follow", "subject": "b" } ], + "pagination": { "hasMore": false } }""", + ) + val store = FakeLastSeenNotificationStore(initial = null) + val raiser = RecordingSystemNotificationRaiser() + + val result = runner(store, raiser).run() + + assertThat(result).isEqualTo(NotificationPollRunner.Result.SUCCESS) + assertThat(raiser.batches).isEmpty() + assertThat(store.lastSeenId()).isEqualTo("3") + } + + @Test + fun `only new items since last-seen are posted and the marker advances`() = runTest(dispatcher) { + enqueue( + """{ "data": [ { "id": "5", "type": "mention", "subject": "e" }, + { "id": "4", "type": "follow", "subject": "d" }, + { "id": "3", "type": "follow", "subject": "c" } ], + "pagination": { "hasMore": false } }""", + ) + val store = FakeLastSeenNotificationStore(initial = "3") + val raiser = RecordingSystemNotificationRaiser() + + runner(store, raiser).run() + + assertThat(raiser.postedIds).containsExactly("5", "4").inOrder() + assertThat(store.lastSeenId()).isEqualTo("5") + } + + @Test + fun `push-disabled events are filtered out of the tray`() = runTest(dispatcher) { + enqueue( + """{ "data": [ { "id": "5", "type": "follow", "subject": "e" }, + { "id": "4", "type": "mention", "subject": "d" } ], + "pagination": { "hasMore": false } }""", + ) + val store = FakeLastSeenNotificationStore(initial = "3") + val raiser = RecordingSystemNotificationRaiser() + val prefs = prefsRepo( + ApiResult.Success( + listOf( + NotificationPreference( + key = "follow", + label = "follow", + description = "", + channels = mapOf(NotificationChannel.PUSH to false), + ), + ), + ), + ) + + runner(store, raiser, prefs).run() + + // "5" (follow) is dropped; "4" (mention, unmapped) defaults to notify. + assertThat(raiser.postedIds).containsExactly("4") + // Marker still advances to the true newest. + assertThat(store.lastSeenId()).isEqualTo("5") + } + + @Test + fun `no new items is a no-op post`() = runTest(dispatcher) { + enqueue( + """{ "data": [ { "id": "9", "type": "follow", "subject": "i" } ], + "pagination": { "hasMore": false } }""", + ) + val store = FakeLastSeenNotificationStore(initial = "9") + val raiser = RecordingSystemNotificationRaiser() + + runner(store, raiser).run() + + assertThat(raiser.batches).isEmpty() + assertThat(store.lastSeenId()).isEqualTo("9") + } + + @Test + fun `empty page posts nothing and keeps the marker`() = runTest(dispatcher) { + enqueue("""{ "data": [], "pagination": { "hasMore": false } }""") + val store = FakeLastSeenNotificationStore(initial = "4") + val raiser = RecordingSystemNotificationRaiser() + + runner(store, raiser).run() + + assertThat(raiser.batches).isEmpty() + assertThat(store.lastSeenId()).isEqualTo("4") + } + + @Test + fun `fetch failure asks WorkManager to retry`() = runTest(dispatcher) { + enqueue("""{ "error": "boom" }""", code = 500) + val store = FakeLastSeenNotificationStore(initial = "4") + val raiser = RecordingSystemNotificationRaiser() + + val result = runner(store, raiser).run() + + assertThat(result).isEqualTo(NotificationPollRunner.Result.RETRY) + assertThat(raiser.batches).isEmpty() + // Marker untouched on failure. + assertThat(store.lastSeenId()).isEqualTo("4") + } + + @Test + fun `preferences failure falls back to default-notify`() = runTest(dispatcher) { + enqueue( + """{ "data": [ { "id": "5", "type": "follow", "subject": "e" } ], + "pagination": { "hasMore": false } }""", + ) + val store = FakeLastSeenNotificationStore(initial = "3") + val raiser = RecordingSystemNotificationRaiser() + val prefs = prefsRepo( + ApiResult.Failure(com.interlinedlist.android.core.common.result.AppError.Unknown("x")), + ) + + runner(store, raiser, prefs).run() + + // Preferences unavailable -> default notify -> "5" is posted. + assertThat(raiser.postedIds).containsExactly("5") + assertThat(store.lastSeenId()).isEqualTo("5") + } +} diff --git a/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationPushFilterTest.kt b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationPushFilterTest.kt new file mode 100644 index 0000000..33e293e --- /dev/null +++ b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationPushFilterTest.kt @@ -0,0 +1,105 @@ +package com.interlinedlist.android.feature.notifications.push + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.feature.notifications.domain.Notification +import com.interlinedlist.android.feature.notifications.domain.NotificationChannel +import com.interlinedlist.android.feature.notifications.domain.NotificationPreference +import com.interlinedlist.android.feature.notifications.domain.NotificationType +import org.junit.Test + +class NotificationPushFilterTest { + + private fun notif(id: String, type: NotificationType) = Notification( + id = id, + type = type, + actor = null, + subject = "s", + body = null, + createdAt = null, + read = false, + target = null, + ) + + private fun pref(key: String, push: Boolean) = NotificationPreference( + key = key, + label = key, + description = "", + channels = mapOf( + NotificationChannel.PUSH to push, + NotificationChannel.IN_APP to true, + ), + ) + + @Test + fun `push-disabled event is filtered out`() { + val filter = NotificationPushFilter(listOf(pref("follow", push = false))) + + assertThat(filter.shouldNotify(notif("1", NotificationType.FOLLOW))).isFalse() + } + + @Test + fun `push-enabled event is kept`() { + val filter = NotificationPushFilter(listOf(pref("follow", push = true))) + + assertThat(filter.shouldNotify(notif("1", NotificationType.FOLLOW))).isTrue() + } + + @Test + fun `key matching is case-insensitive`() { + val filter = NotificationPushFilter(listOf(pref("FoLLoW", push = false))) + + assertThat(filter.shouldNotify(notif("1", NotificationType.FOLLOW))).isFalse() + } + + @Test + fun `unmapped event defaults to notify`() { + // No preference for the DIG/like category at all. + val filter = NotificationPushFilter(listOf(pref("follow", push = false))) + + assertThat(filter.shouldNotify(notif("1", NotificationType.LIKE))).isTrue() + } + + @Test + fun `empty preferences default to notify`() { + val filter = NotificationPushFilter(emptyList()) + + assertThat(filter.shouldNotify(notif("1", NotificationType.MENTION))).isTrue() + } + + @Test + fun `event without a push channel is treated as allowed`() { + val emailOnly = NotificationPreference( + key = "follow", + label = "follow", + description = "", + channels = mapOf(NotificationChannel.EMAIL to true), // no PUSH modelled + ) + val filter = NotificationPushFilter(listOf(emailOnly)) + + assertThat(filter.shouldNotify(notif("1", NotificationType.FOLLOW))).isTrue() + } + + @Test + fun `filter keeps only push-enabled notifications`() { + val filter = NotificationPushFilter( + listOf( + pref("follow", push = true), + pref("dig", push = false), + ), + ) + val input = listOf( + notif("1", NotificationType.FOLLOW), // kept + notif("2", NotificationType.LIKE), // dropped (dig disabled) + notif("3", NotificationType.MENTION), // kept (unmapped -> default notify) + ) + + assertThat(filter.filter(input).map { it.id }).containsExactly("1", "3").inOrder() + } + + @Test + fun `dig preference maps to the like notification type`() { + val filter = NotificationPushFilter(listOf(pref("dig", push = false))) + + assertThat(filter.shouldNotify(notif("1", NotificationType.LIKE))).isFalse() + } +} diff --git a/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/push/RecordingSystemNotificationRaiser.kt b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/push/RecordingSystemNotificationRaiser.kt new file mode 100644 index 0000000..af9166e --- /dev/null +++ b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/push/RecordingSystemNotificationRaiser.kt @@ -0,0 +1,17 @@ +package com.interlinedlist.android.feature.notifications.push + +import com.interlinedlist.android.feature.notifications.domain.Notification + +/** Records every [post] call so tests can assert which notifications were raised. */ +class RecordingSystemNotificationRaiser : SystemNotificationRaiser { + + /** Each element is the batch handed to one [post] call. */ + val batches = mutableListOf>() + + /** Flattened ids across all batches, in order. */ + val postedIds: List get() = batches.flatten().map { it.id } + + override fun post(items: List) { + batches += items + } +} diff --git a/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/ui/FakeNotificationsRepository.kt b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/ui/FakeNotificationsRepository.kt index a585ccf..49d3fb9 100644 --- a/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/ui/FakeNotificationsRepository.kt +++ b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/ui/FakeNotificationsRepository.kt @@ -18,12 +18,14 @@ class FakeNotificationsRepository : NotificationsRepository { private val notifications = MutableStateFlow>(emptyList()) + var fetchLatestResult: ApiResult> = ApiResult.Success(emptyList()) var refreshResult: ApiResult = ApiResult.Success(false) var loadMoreResult: ApiResult = ApiResult.Success(false) var markReadResult: ApiResult = ApiResult.Success(Unit) var markAllReadResult: ApiResult = ApiResult.Success(Unit) var dismissResult: ApiResult = ApiResult.Success(Unit) + var fetchLatestCount = 0 var refreshCount = 0 var loadMoreCount = 0 var markReadIds = mutableListOf() @@ -37,6 +39,11 @@ class FakeNotificationsRepository : NotificationsRepository { override fun observeUnreadCount(): Flow = notifications.map { list -> list.count { !it.read } } + override suspend fun fetchLatest(): ApiResult> { + fetchLatestCount++ + return fetchLatestResult + } + override suspend fun refresh(): ApiResult { refreshCount++ return refreshResult