Compare commits

..

9 Commits

Author SHA1 Message Date
0c6f0bc2c7 keyboard jumpy fix 2026-03-03 21:56:13 +00:00
9114b3189e UI/UX fixes 2026-03-03 20:39:41 +00:00
276d2f2615 push notifications 2026-03-03 20:32:53 +00:00
8c0cbac246 DMs baseline 2026-03-03 15:11:54 +00:00
82890d85ba gif and better reactions 2026-03-03 11:48:15 +00:00
6a87a33ea0 custom emojis 2026-03-02 23:14:44 +00:00
2b554dc227 better emoji reactions 2026-03-02 22:21:23 +00:00
2169f28632 version bump 2026-03-02 18:21:36 +00:00
21aed4f682 sync fix and reactions 2026-03-02 18:20:07 +00:00
35 changed files with 3182 additions and 249 deletions

View File

@@ -0,0 +1,53 @@
# Android Bug Hunter — Project Memory
## Recurring Patterns Found
### NotificationHelper: runBlocking on Service Thread
`NotificationHelper.show()` calls `runBlocking` three times (notificationsEnabled, mutedRooms, roomNameCache).
This blocks the `PushService` callback thread. Fix: pass pre-collected values in or make `show()` a suspend fun.
See: `push/NotificationHelper.kt`
### FluffytrixPushService: Leaking CoroutineScope
`FluffytrixPushService` creates its own `CoroutineScope(SupervisorJob() + Dispatchers.IO)` but never cancels
it when the service is destroyed. Coroutines launched from `onNewEndpoint`/`onUnregistered` can outlive the
service. Fix: cancel scope in `onDestroy()`.
### PushRegistrationManager: OkHttp response body not closed
`registerPusher()` stores `response.body?.string()` (which auto-closes), but the `execute()` call itself is
never inside a `use {}` block — if `body?.string()` throws, the response is not closed.
Fix: wrap in `response.use { ... }`.
### build.gradle.kts: API key in plain BuildConfig
`TENOR_API_KEY` hardcoded in `buildConfigField` — visible in plaintext in the APK's BuildConfig class.
### MainViewModel: `onUpdate` listener launches on `Dispatchers.Default` inside `viewModelScope`
The timeline listener's `onUpdate` callback launches a coroutine with `viewModelScope.launch(Dispatchers.Default)`.
If the ViewModel is cleared mid-flight the scope is cancelled but the `onUpdate` lambda (captured by the
SDK via JNI) can still fire and attempt `launch` on a cancelled scope — harmless but noisy.
### MainViewModel: `CoroutineScope` inside `loadTimeline` shared across closures
A `Mutex` local to `loadTimeline` is captured by the `TimelineListener` lambda. When `loadTimeline` is called
again for a new room, the old listener still holds the old mutex — correct, but produces confusing parallelism.
### MainViewModel: `sendGif` creates a new OkHttpClient per call
A brand-new `OkHttpClient()` is constructed inside `sendGif` on every invocation. Should reuse a shared
client (like the one in `PushRegistrationManager`).
### SettingsScreen: new OkHttpClient per API-key test
A new `OkHttpClient()` is created on every "Save" button press inside `SettingsScreen`. Leaks the connection
pool after the composable is disposed.
### DeepLinkState: global singleton StateFlow — never clears on logout
`DeepLinkState` is a process-level `object`. After logout/re-login the stale `pendingRoomId` can navigate
the new session to the old room.
### PreferencesManager: `notificationsEnabled` default logic is inverted-looking
`prefs[KEY_NOTIFICATIONS_ENABLED] != false` — treats missing key as `true` (desired), but any corruption
storing a non-boolean type would also return `true`. Low risk but worth noting.
## Key File Paths
- Push infra: `app/src/main/java/com/example/fluffytrix/push/`
- ViewModel: `app/src/main/java/com/example/fluffytrix/ui/screens/main/MainViewModel.kt`
- Settings UI: `app/src/main/java/com/example/fluffytrix/ui/screens/settings/SettingsScreen.kt`
- DI: `app/src/main/java/com/example/fluffytrix/di/AppModule.kt`
- Prefs: `app/src/main/java/com/example/fluffytrix/data/local/PreferencesManager.kt`

View File

@@ -0,0 +1,20 @@
# UI/UX Reviewer Memory — Fluffytrix
See `patterns.md` for detailed design conventions.
## Key Conventions (short form)
- Spacing rhythm: 4/6/8/10/12/16/24/32dp — no odd values
- Channel list item padding: horizontal 12dp, vertical 10dp
- Settings screen row padding: vertical 6dp (info rows), vertical 12dp (nav rows)
- Icon size in lists: 20dp (channel icons, drag handles); 32dp (member avatars)
- Space sidebar icons: 48dp touch target, 64dp column width
- Color tokens: always MaterialTheme.colorScheme — EXCEPT senderColors array (Discord palette, intentional)
- Two hardcoded colors in SpaceList unread dots: `Color.Red` and `Color.Gray` — inconsistent with ChannelList which uses `colorScheme.error` and `colorScheme.primary`
- Typography: titleMedium for section headers in panels; labelMedium uppercase for channel sections; bodyLarge for channel names; bodyMedium for settings labels; bodySmall for subtitles
- Icon style: filled (Icons.Default.*) throughout — AutoMirrored used for directional icons
- Scaffold used in SettingsScreen with TopAppBar; MainScreen Scaffold has no topBar (custom layout)
- `collectAsStateWithLifecycle` used in MainScreen; `collectAsState` used in SettingsScreen — inconsistent
- MainScreen uses `@Immutable` data class for ProfileSheetState (good practice)
- Drag gesture threshold: 60f px for swipe-open/close channel list
- LazyListState for channel list is owned by ViewModel (correct — survives recomposition)
- IME insets: enableEdgeToEdge() is active; Scaffold default contentWindowInsets excludes IME; MessageTimeline Column must carry `.imePadding()` before static padding — without it the keyboard overlays the input bar. AndroidView EditText does not auto-participate in Compose IME avoidance. Never use windowSoftInputMode=adjustResize with edge-to-edge.

View File

@@ -0,0 +1,40 @@
# Fluffytrix UI Patterns
## Layout Structure
- MainScreen: Scaffold (no topBar) → Box → Row[SpaceList | MessageTimeline | MemberList?]
- Channel list overlays via AnimatedVisibility + zIndex(1f), slides from left
- SpaceList: 64dp wide, surfaceVariant background, icons 48dp
- ChannelList: fills width, surface background, max ~280dp visually
- MemberList: 240dp wide, surface background
- SettingsScreen: Scaffold + TopAppBar + Column + verticalScroll
## Spacing Rhythm
- Standard horizontal content padding: 16dp (settings, channel list header)
- Channel list items: horizontal 12dp, vertical 10dp
- Thread sub-items: start indent 24dp, horizontal 10dp, vertical 6dp
- Member list items: horizontal 8dp, vertical 4dp
- Settings rows: vertical 6dp (info/toggle), vertical 12dp (nav)
- Section dividers: padding(vertical = 16.dp)
## Color Token Usage
- Surface backgrounds: `colorScheme.surface` or `colorScheme.surfaceVariant`
- Selected item bg: `colorScheme.primaryContainer`; text: `colorScheme.onPrimaryContainer`
- Unread dot in ChannelList: error (mention) / primary (unread) — correct
- Unread dot in SpaceList: hardcoded `Color.Red` / `Color.Gray` — BUG, inconsistent
- Section text: `colorScheme.onSurface.copy(alpha = 0.7f)` uppercase labelMedium
- Disabled/subtle text: `colorScheme.onSurfaceVariant` or with `.copy(alpha = 0.6f)`
## Icon Conventions
- All icons: Icons.Default.* (filled style)
- Directional icons: Icons.AutoMirrored.Filled.*
- Icon size in list rows: 20dp
- All icons in touch targets: wrapped in IconButton (48dp auto-sizing) or clickable with min 48dp
## Notification (push/) — no Compose UI
- NotificationHelper: uses `R.mipmap.ic_launcher` as small icon (should be `R.drawable` monochrome)
- Channel: "messages" / IMPORTANCE_DEFAULT
- Tap → deep link via Intent extra "roomId" → DeepLinkState.set()
## State Collection
- MainScreen: collectAsStateWithLifecycle (correct, lifecycle-aware)
- SettingsScreen: collectAsState (should be collectAsStateWithLifecycle for consistency)

View File

@@ -0,0 +1,124 @@
---
name: ui-ux-reviewer
description: "Use this agent when UI components, screens, or navigation flows are added or modified. It reviews Jetpack Compose code for visual consistency, Material 3 adherence, and user experience quality.\\n\\nExamples:\\n\\n- User: \"Add a settings screen with toggle options\"\\n Assistant: *writes the settings screen composable*\\n \"Now let me use the ui-ux-reviewer agent to review the new screen for consistency and UX quality.\"\\n *launches ui-ux-reviewer agent via Task tool*\\n\\n- User: \"Update the channel list to show unread badges\"\\n Assistant: *implements unread badge composable and integrates it*\\n \"Let me run the ui-ux-reviewer agent to ensure the badges are consistent with the rest of the app's design language.\"\\n *launches ui-ux-reviewer agent via Task tool*\\n\\n- User: \"Fix the message input bar layout\"\\n Assistant: *modifies the input bar composable*\\n \"I'll use the ui-ux-reviewer agent to verify the layout changes maintain consistency and good UX.\"\\n *launches ui-ux-reviewer agent via Task tool*"
model: sonnet
memory: project
---
You are an elite UI/UX reviewer specializing in Android Jetpack Compose applications with Material 3 / Material You theming. You have deep expertise in building consistent, accessible, and intuitive interfaces. Your particular strength is Discord-like chat application layouts.
## Your Role
You review recently written or modified UI code to ensure visual consistency across the entire app and a smooth, intuitive user experience. You do NOT rewrite the whole codebase — you focus on the recently changed files and check them against established patterns.
## Project Context
This is Fluffytrix, an Android Matrix chat client with a Discord-like UI:
- Jetpack Compose UI with Material 3 dynamic colors
- Discord-like layout: space sidebar → channel list → message area → member list
- Package: `com.example.fluffytrix`
- Target: Android 14+ (minSdk 34)
## Review Process
1. **Identify changed/new UI files** — focus your review on recently modified composables and screens.
2. **Check consistency** by examining existing UI patterns in the codebase:
- Read several existing screens/components to establish the baseline patterns
- Compare the new code against those patterns
- Look for: padding values, color usage, typography styles, icon sizing, elevation, shape/corner radius, spacing rhythm
3. **Evaluate UX quality**:
- Touch target sizes (minimum 48dp)
- Loading states — are they present where needed?
- Error states — are they handled gracefully?
- Empty states — do lists show meaningful empty content?
- Navigation clarity — is it obvious how to go back or proceed?
- Feedback — do interactive elements provide visual feedback (ripple, state changes)?
- Scrolling behavior — is content scrollable when it could overflow?
- Keyboard handling — does the UI adapt when the soft keyboard appears?
4. **Check Material 3 adherence**:
- Uses `MaterialTheme.colorScheme` tokens instead of hardcoded colors
- Uses `MaterialTheme.typography` instead of custom text styles
- Proper use of Surface, Card, and container components
- Consistent use of Material 3 icons (filled vs outlined — pick one style)
- Dynamic color support (no colors that break with light/dark theme)
5. **Accessibility**:
- Content descriptions on icons and images
- Sufficient color contrast
- Semantic properties for screen readers
- Text scaling support (don't use fixed sp that breaks at large font sizes)
## Output Format
Structure your review as:
### ✅ Consistent Patterns
List what the code does well and matches existing patterns.
### ⚠️ Inconsistencies Found
For each issue:
- **File**: path
- **Issue**: what's wrong
- **Expected**: what the pattern should be (with reference to where the correct pattern exists)
- **Fix**: concrete code suggestion
### 🎯 UX Improvements
Suggestions that aren't bugs but would improve the user experience.
Prioritize issues by impact: blocking issues first, then visual inconsistencies, then nice-to-haves.
## Rules
- Always read existing UI code first to understand established patterns before making judgments
- Never suggest changes that would break the Discord-like layout intent
- Prefer MaterialTheme tokens over any hardcoded values
- If you're unsure whether something is intentional, flag it as a question rather than an error
- Keep suggestions actionable — include code snippets for fixes
- Don't nitpick formatting; focus on user-visible consistency and experience
**Update your agent memory** as you discover UI patterns, design conventions, component reuse patterns, color/spacing constants, and navigation structures in this codebase. This builds up institutional knowledge across conversations. Write concise notes about what you found and where.
Examples of what to record:
- Common padding/spacing values used across screens
- Standard composable patterns (e.g., how list items are structured)
- Color token usage conventions
- Icon style choices (filled vs outlined)
- Navigation patterns and screen transition styles
- Reusable component locations
# Persistent Agent Memory
You have a persistent Persistent Agent Memory directory at `/home/mrfluffy/Documents/projects/Android/fluffytrix/.claude/agent-memory/ui-ux-reviewer/`. Its contents persist across conversations.
As you work, consult your memory files to build on previous experience. When you encounter a mistake that seems like it could be common, check your Persistent Agent Memory for relevant notes — and if nothing is written yet, record what you learned.
Guidelines:
- `MEMORY.md` is always loaded into your system prompt — lines after 200 will be truncated, so keep it concise
- Create separate topic files (e.g., `debugging.md`, `patterns.md`) for detailed notes and link to them from MEMORY.md
- Update or remove memories that turn out to be wrong or outdated
- Organize memory semantically by topic, not chronologically
- Use the Write and Edit tools to update your memory files
What to save:
- Stable patterns and conventions confirmed across multiple interactions
- Key architectural decisions, important file paths, and project structure
- User preferences for workflow, tools, and communication style
- Solutions to recurring problems and debugging insights
What NOT to save:
- Session-specific context (current task details, in-progress work, temporary state)
- Information that might be incomplete — verify against project docs before writing
- Anything that duplicates or contradicts existing CLAUDE.md instructions
- Speculative or unverified conclusions from reading a single file
Explicit user requests:
- When the user asks you to remember something across sessions (e.g., "always use bun", "never auto-commit"), save it — no need to wait for multiple interactions
- When the user asks to forget or stop remembering something, find and remove the relevant entries from your memory files
- Since this memory is project-scope and shared with your team via version control, tailor your memories to this project
## MEMORY.md
Your MEMORY.md is currently empty. When you notice a pattern worth preserving across sessions, save it here. Anything in MEMORY.md will be included in your system prompt next time.

View File

@@ -13,7 +13,9 @@
"WebFetch(domain:raw.githubusercontent.com)", "WebFetch(domain:raw.githubusercontent.com)",
"Bash(export TERM=dumb:*)", "Bash(export TERM=dumb:*)",
"Bash(grep:*)", "Bash(grep:*)",
"Bash(jar tf:*)" "Bash(jar tf:*)",
"Bash(javap:*)",
"Bash(jar xf:*)"
] ]
} }
} }

13
.idea/deviceManager.xml generated Normal file
View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceTable">
<option name="columnSorters">
<list>
<ColumnSorterState>
<option name="column" value="Name" />
<option name="order" value="ASCENDING" />
</ColumnSorterState>
</list>
</option>
</component>
</project>

6
.idea/studiobot.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="StudioBotProjectSettings">
<option name="shareContext" value="OptedIn" />
</component>
</project>

163
AGENTS.md
View File

@@ -5,8 +5,8 @@
Fluffytrix is an Android Matrix chat client with a Discord-like UI. Built with Kotlin, targeting Android 14+ (minSdk 34, targetSdk 36, compileSdk 36). Fluffytrix is an Android Matrix chat client with a Discord-like UI. Built with Kotlin, targeting Android 14+ (minSdk 34, targetSdk 36, compileSdk 36).
**Package**: `com.example.fluffytrix` **Package**: `com.example.fluffytrix`
**Build system**: Gradle with Kotlin DSL, version catalog at `gradle/libs.versions.toml` **Build system**: Gradle with KotlinDSL, version catalog at `gradle/libs.versions.toml`
**Container/DI**: Koin **DI**: Koin
**State management**: Jetpack Compose StateFlow, ViewModel **State management**: Jetpack Compose StateFlow, ViewModel
**UI framework**: Jetpack Compose with Material 3 (Dynamic Colors) **UI framework**: Jetpack Compose with Material 3 (Dynamic Colors)
**Protocol**: Trixnity SDK for Matrix **Protocol**: Trixnity SDK for Matrix
@@ -20,28 +20,22 @@ Fluffytrix is an Android Matrix chat client with a Discord-like UI. Built with K
```bash ```bash
./gradlew assembleDebug # Build debug APK (minified for performance) ./gradlew assembleDebug # Build debug APK (minified for performance)
./gradlew assembleRelease # Build release APK ./gradlew assembleRelease # Build release APK
./gradlew test # Run unit tests ./gradlew test # Run all unit tests
./gradlew connectedAndroidTest # Run instrumented tests on device/emulator ./gradlew connectedAndroidTest # Run instrumented tests on device
./gradlew testDebugUnitTest --tests "com.example.fluffytrix.ExampleUnitTest" # Run single test ./gradlew testDebugUnitTest --tests "com.example.fluffytrix.*" # Run single test class
./gradlew app:testDebugUnitTest --tests "LoginViewModelTest" # Run specific test
``` ```
**Notes**: - Debug builds use R8 with `isDebuggable = true` for balanced speed/debuggability
- Debug builds use R8 with `isDebuggable = true` but strip material-icons-extended and optimize Compose for performance
- Use `--tests` with Gradle test tasks to run a single test class
- Instrumented tests require a connected device or emulator - Instrumented tests require a connected device or emulator
--- ---
## Testing ## Testing
**Unit tests**: Located in `app/src/test/java/`, run with `./gradlew test` **Unit tests**: `app/src/test/java/`
**Instrumented tests**: Located in `app/src/androidTest/java/`, run with `./gradlew connectedAndroidTest` **Instrumented tests**: `app/src/androidTest/java/`
**Single test**: `./gradlew testDebugUnitTest --tests "com.example.fluffytrix.ui.main.LoginViewModelTest"`
**Single test execution**:
```bash
./gradlew testDebugUnitTest --tests "com.example.fluffytrix.*"
./gradlew app:testDebugUnitTest --tests "ExampleUnitTest"
```
--- ---
@@ -49,17 +43,17 @@ Fluffytrix is an Android Matrix chat client with a Discord-like UI. Built with K
### Kotlin Conventions ### Kotlin Conventions
- **Android Official Kotlin style** (`kotlin.code.style=official` in gradle.properties) - **Android Official Kotlin style** (`kotlin.code.style=official`)
- File naming: `PascalCase.kt` (e.g., `MainViewModel.kt`) - File naming: `PascalCase.kt` (e.g., `MainViewModel.kt`)
- Class naming: `PascalCase` (e.g., `MainViewModel`, `AuthRepository`) - Class naming: `PascalCase` (e.g., `AuthRepository`, `MainViewModel`)
- Function/property naming: `camelCase` (e.g., `sendMessage`, `selectedChannel`) - Function/property naming: `camelCase` (e.g., `sendMessage`, `selectedChannel`)
- Constant naming: `PascalCase` for top-level constants - Constants: `PascalCase` for top-level constants
- **Never use underscores in variable names** - **Never use underscores in variable names**
### Imports ### Imports
- Explicit imports only (no wildcard imports) - Explicit imports only (no wildcards)
- Group imports: Android/X → Kotlin → Javax/Java → Third-party → Same package - Group: Android/X → Kotlin → Javax/Java → Third-party → Same package
- Example: - Example:
```kotlin ```kotlin
import android.os.Bundle import android.os.Bundle
@@ -69,59 +63,37 @@ Fluffytrix is an Android Matrix chat client with a Discord-like UI. Built with K
import com.example.fluffytrix.ui.theme.FluffytrixTheme import com.example.fluffytrix.ui.theme.FluffytrixTheme
``` ```
### Types ### Types & Error Handling
- Prefer `val` over `var` (immutable data) - Prefer `val` over `var` (immutable data)
- Use `StateFlow<T>` for observable state in ViewModels - Use `StateFlow<T>` for observable ViewModel state, `Flow<T>` for read-only streams
- Use `Flow<T>` for read-only data streams - Use `suspend` for async operations, `Result<T>` for failing operations
- Use `suspend` functions for async operations - `try-catch` with `catch (_: Exception) { }` for graceful degradation in ViewModels
- Use `Result<T>` for operations that can fail - Use `?:` operators when appropriate, **never crash on recoverable errors**
### Error Handling ### Compose & ViewModels
- Prefer `try-catch` with silent failure or `?` operators where appropriate - Use `@Composable` for all UI functions; use `MaterialTheme` for consistent theming
- In ViewModels, use `catch (_: Exception) { }` or `?:` for graceful degradation - Discord-like layout: space sidebar → channel list → message area → member list
- Expose error state via `StateFlow<AuthState>` where users need feedback - Use `Modifier.padding()`, `wrapContentWidth()`, `fillMaxWidth()` appropriately
- **Never crash the app on recoverable errors** - Inject deps via constructor with Koin; use `viewModelScope` for coroutines
- Cache expensive operations (e.g., `messageCache`, `memberCache`)
### Compose UI ### Coroutines & Data Layer
- Use `@Composable` for all UI functions - `Dispatchers.Default` for CPU work, `Dispatchers.IO` for I/O operations
- Follow Discord-like layout: space sidebar → channel list → message area → member list - Cancel jobs on ViewModel cleanup: `job?.cancel()`
- Use `MaterialTheme` for consistent theming - Use `Room` for persistent storage; `DataStore Preferences` for small key-value data
- Prefer `Modifier.padding()` over nested `Box` with margins - Prefer Flow-based APIs; cache in ViewModels
- Use `wrapContentWidth()` and `fillMaxWidth()` appropriately
- For columnar layouts: `Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp))`
### ViewModels
- Inject dependencies via constructor (Koin)
- Use `viewModelScope` for coroutines
- Expose state via `StateFlow` (e.g., `val messages: StateFlow<List<MessageItem>>`)
- Use `MutableStateFlow` for internal state, expose as read-only `StateFlow`
### Coroutines
- Use `Dispatchers.Default` for CPU-intensive work (parsing, filtering)
- Use `Dispatchers.IO` for file/network operations
- Always cancel jobs on ViewModel cleanup: `job?.cancel()`
- Use `withContext(Dispatchers.Default)` to switch threads explicitly
### Data Layer
- Use Room for persistent storage (Trixnity uses Room internally)
- Use DataStore Preferences for small key-value data
- Prefer Flow-based APIs for reactive data streams
- Cache expensive operations in ViewModel (e.g., `messageCache`, `memberCache`)
### Naming Conventions ### Naming Conventions
- State Flow properties: `_name` (private) / `name` (public) - State Flow: `_state` (private) / `state` (public)
- Repository class: `AuthService`, `AuthRepository` - Repositories: `AuthRepository`, `MessageRepository`
- ViewModel class: `MainViewModel`, `LoginViewModel` - ViewModels: `MainViewModel`, `LoginViewModel`
- UI composable: `MainScreen`, `ChannelList`, `MessageItem` - UI composables: `MainScreen`, `ChannelList`, `MessageItem`
- Model data class: `MessageItem`, `ChannelItem`, `SpaceItem` - Models: `MessageItem`, `ChannelItem`, `SpaceItem`
- Use `full` property for Matrix IDs (e.g., `userId.full`, `roomId.full`) - Matrix IDs: Use `RoomId`, `UserId` types; access `.full` for strings
--- ---
@@ -130,62 +102,19 @@ Fluffytrix is an Android Matrix chat client with a Discord-like UI. Built with K
**Layered Architecture**: **Layered Architecture**:
``` ```
ui/ — ViewModels, Screens, Navigation ui/ — ViewModels, Screens, Navigation
data/ — Repositories, local storage, models data/ — Repositories, storage, models
di/ — Koin modules di/ — Koin modules
ui/theme/ — Material 3 Theme (colors, typography) ui/theme/ — Material 3 Theme
``` ```
**Dependency Injection**: Koin with two modules: **Koin DI**:
- `appModule`: ViewModels (`viewModel { MainViewModel(...) }`) - `appModule`: ViewModel injection
- `dataModule`: singleton services (`single { AuthRepository(...) }`) - `dataModule`: Singleton repositories/UI Flow: NavHost in `MainActivity` with `FluffytrixTheme`, ViewModels expose StateFlow, screens observe with `collectAsState()`
**UI Flow**:
1. `FluffytrixNavigation` handles nav graph and session restoration
2. `MainActivity` hosts the NavHost with `FluffytrixTheme`
3. ViewModels expose state via StateFlow
4. Screens observe state with `collectAsState()`
--- ---
## Key Dependencies (from libs.versions.toml) ## Key Dependencies
- **Compose BOM**: `2025.06.00` - **Compose BOM**: `2025.06.00`, **Kotlin**: `2.2.10`, **AGP**: `9.0.1`
- **Kotlin**: `2.2.10` - **Koin**: `4.1.1`, **Trixnity**: `4.22.7`, **Ktor**: `3.3.0`
- **AGP**: `9.0.1` - **Coroutines**: `1.10.2`, **DataStore**: `1.1.7`, **Coil**: `3.2.0`
- **Koin**: `4.1.1`
- **Trixnity**: `4.22.7`
- **Ktor**: `3.3.0`
- **Coroutines**: `1.10.2`
- **DataStore**: `1.1.7`
- **Coil**: `3.2.0`
- **Media3**: `1.6.0`
---
## Special Notes
1. **Matrix IDs**: Use `RoomId`, `UserId` types from Trixnity; access `.full` for string representation
2. **MXC URLs**: Convert with `MxcUrlHelper.mxcToDownloadUrl()` and `mxcToThumbnailUrl()`
3. **Build Performance**: Debug builds use R8 minification with `isDebuggable = true` to balance speed and debuggability
4. **Channel Reordering**: Channel order is saved per-space in DataStore and restored on navigation
5. **Encrypted Rooms**: Handle decryption state—messages may appear as `Unable to decrypt` until keys arrive
6. **Theme**: Material 3 with Discord-inspired color scheme (primary: `#5865F2`)
---
## Running Lint/Checks
```bash
./gradlew lintDebug
./gradlew spotlessCheck
```
## CI/CD
- All builds use Gradle wrapper (`./gradlew`)
- No manual Gradle installation required (project uses Gradle 9.1.0)

View File

@@ -15,7 +15,7 @@ android {
minSdk = 34 minSdk = 34
targetSdk = 36 targetSdk = 36
versionCode = 1 versionCode = 1
versionName = "1.0" versionName = "1.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -41,7 +41,9 @@ android {
} }
buildFeatures { buildFeatures {
compose = true compose = true
buildConfig = true
} }
packaging { packaging {
dex { dex {
useLegacyPackaging = true useLegacyPackaging = true
@@ -101,6 +103,12 @@ dependencies {
implementation(libs.markdown.renderer.code) implementation(libs.markdown.renderer.code)
implementation(libs.markdown.renderer.coil3) implementation(libs.markdown.renderer.coil3)
// Jetpack Emoji Picker
implementation(libs.emoji.picker)
// UnifiedPush
implementation(libs.unifiedpush)
testImplementation(libs.junit) testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.core)

View File

@@ -3,6 +3,8 @@
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application <application
android:name=".FluffytrixApplication" android:name=".FluffytrixApplication"
@@ -16,13 +18,32 @@
android:theme="@style/Theme.Fluffytrix"> android:theme="@style/Theme.Fluffytrix">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true"> android:exported="true"
android:launchMode="singleTop"
android:windowSoftInputMode="adjustNothing">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<service
android:name=".push.FluffytrixPushService"
android:exported="false">
<intent-filter>
<action android:name="org.unifiedpush.android.connector.PUSH_EVENT" />
</intent-filter>
</service>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_provider_paths" />
</provider>
</application> </application>
</manifest> </manifest>

View File

@@ -9,21 +9,42 @@ import coil3.memory.MemoryCache
import coil3.gif.AnimatedImageDecoder import coil3.gif.AnimatedImageDecoder
import coil3.video.VideoFrameDecoder import coil3.video.VideoFrameDecoder
import coil3.network.okhttp.OkHttpNetworkFetcherFactory import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import com.example.fluffytrix.data.local.PreferencesManager
import com.example.fluffytrix.data.repository.AuthRepository import com.example.fluffytrix.data.repository.AuthRepository
import com.example.fluffytrix.di.appModule import com.example.fluffytrix.di.appModule
import com.example.fluffytrix.di.dataModule import com.example.fluffytrix.di.dataModule
import com.example.fluffytrix.push.NotificationHelper
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidContext
import org.koin.core.context.startKoin import org.koin.core.context.startKoin
import org.unifiedpush.android.connector.UnifiedPush
class FluffytrixApplication : Application(), SingletonImageLoader.Factory { class FluffytrixApplication : Application(), SingletonImageLoader.Factory {
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
startKoin { startKoin {
androidContext(this@FluffytrixApplication) androidContext(this@FluffytrixApplication)
modules(appModule, dataModule) modules(appModule, dataModule)
} }
NotificationHelper.ensureChannel(this)
val preferencesManager: PreferencesManager by inject()
appScope.launch {
val loggedIn = preferencesManager.isLoggedIn.first()
if (loggedIn) {
// Only register if a distributor is already saved; user must select one manually otherwise
if (UnifiedPush.getSavedDistributor(this@FluffytrixApplication) != null) {
UnifiedPush.register(this@FluffytrixApplication)
}
}
}
} }
override fun newImageLoader(context: coil3.PlatformContext): ImageLoader { override fun newImageLoader(context: coil3.PlatformContext): ImageLoader {

View File

@@ -1,20 +1,41 @@
package com.example.fluffytrix package com.example.fluffytrix
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import com.example.fluffytrix.push.DeepLinkState
import com.example.fluffytrix.ui.navigation.FluffytrixNavigation import com.example.fluffytrix.ui.navigation.FluffytrixNavigation
import com.example.fluffytrix.ui.theme.FluffytrixTheme import com.example.fluffytrix.ui.theme.FluffytrixTheme
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val requestNotificationPermission =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* no-op */ }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED
) {
requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
}
intent.getStringExtra("roomId")?.let { DeepLinkState.set(it) }
setContent { setContent {
FluffytrixTheme { FluffytrixTheme {
FluffytrixNavigation() FluffytrixNavigation()
} }
} }
} }
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
intent.getStringExtra("roomId")?.let { DeepLinkState.set(it) }
}
} }

View File

@@ -31,6 +31,12 @@ class PreferencesManager(private val context: Context) {
private val KEY_HIDE_SPACES_WHEN_CLOSED = booleanPreferencesKey("hide_spaces_when_closed") private val KEY_HIDE_SPACES_WHEN_CLOSED = booleanPreferencesKey("hide_spaces_when_closed")
private val KEY_THREAD_NAMES = stringPreferencesKey("thread_names") private val KEY_THREAD_NAMES = stringPreferencesKey("thread_names")
private val KEY_HIDDEN_THREADS = stringPreferencesKey("hidden_threads") private val KEY_HIDDEN_THREADS = stringPreferencesKey("hidden_threads")
private val KEY_TENOR_API_KEY = stringPreferencesKey("tenor_api_key")
private val KEY_LAST_OPENED_ROOM = stringPreferencesKey("last_opened_room")
private val KEY_UP_ENDPOINT = stringPreferencesKey("up_endpoint")
private val KEY_ROOM_NAME_CACHE = stringPreferencesKey("room_name_cache")
private val KEY_NOTIFICATIONS_ENABLED = booleanPreferencesKey("notifications_enabled")
private val KEY_MUTED_ROOMS = stringPreferencesKey("muted_rooms")
} }
val isLoggedIn: Flow<Boolean> = context.dataStore.data.map { prefs -> val isLoggedIn: Flow<Boolean> = context.dataStore.data.map { prefs ->
@@ -125,6 +131,16 @@ class PreferencesManager(private val context: Context) {
} }
} }
val tenorApiKey: Flow<String> = context.dataStore.data.map { prefs ->
prefs[KEY_TENOR_API_KEY] ?: ""
}
suspend fun setTenorApiKey(key: String) {
context.dataStore.edit { prefs ->
prefs[KEY_TENOR_API_KEY] = key
}
}
// Thread names: key = "roomId:threadRootEventId", value = custom name // Thread names: key = "roomId:threadRootEventId", value = custom name
val threadNames: Flow<Map<String, String>> = context.dataStore.data.map { prefs -> val threadNames: Flow<Map<String, String>> = context.dataStore.data.map { prefs ->
val raw = prefs[KEY_THREAD_NAMES] ?: return@map emptyMap() val raw = prefs[KEY_THREAD_NAMES] ?: return@map emptyMap()
@@ -158,6 +174,63 @@ class PreferencesManager(private val context: Context) {
} }
} }
val lastOpenedRoom: Flow<String?> = context.dataStore.data.map { prefs ->
prefs[KEY_LAST_OPENED_ROOM]
}
suspend fun setLastOpenedRoom(roomId: String) {
context.dataStore.edit { prefs ->
prefs[KEY_LAST_OPENED_ROOM] = roomId
}
}
val roomNameCache: Flow<Map<String, String>> = context.dataStore.data.map { prefs ->
val raw = prefs[KEY_ROOM_NAME_CACHE] ?: return@map emptyMap()
try { Json.decodeFromString<Map<String, String>>(raw) } catch (_: Exception) { emptyMap() }
}
suspend fun saveRoomNameCache(names: Map<String, String>) {
context.dataStore.edit { prefs ->
prefs[KEY_ROOM_NAME_CACHE] = Json.encodeToString(names)
}
}
val upEndpoint: Flow<String?> = context.dataStore.data.map { prefs ->
prefs[KEY_UP_ENDPOINT]
}
suspend fun setUpEndpoint(url: String?) {
context.dataStore.edit { prefs ->
if (url != null) prefs[KEY_UP_ENDPOINT] = url else prefs.remove(KEY_UP_ENDPOINT)
}
}
val notificationsEnabled: Flow<Boolean> = context.dataStore.data.map { prefs ->
prefs[KEY_NOTIFICATIONS_ENABLED] ?: true
}
suspend fun setNotificationsEnabled(enabled: Boolean) {
context.dataStore.edit { prefs ->
prefs[KEY_NOTIFICATIONS_ENABLED] = enabled
}
}
val mutedRooms: Flow<Set<String>> = context.dataStore.data.map { prefs ->
val raw = prefs[KEY_MUTED_ROOMS] ?: return@map emptySet()
try { Json.decodeFromString<Set<String>>(raw) } catch (_: Exception) { emptySet() }
}
suspend fun toggleRoomMute(roomId: String) {
context.dataStore.edit { prefs ->
val existing = prefs[KEY_MUTED_ROOMS]?.let {
try { Json.decodeFromString<Set<String>>(it) } catch (_: Exception) { emptySet() }
} ?: emptySet()
prefs[KEY_MUTED_ROOMS] = Json.encodeToString(
if (roomId in existing) existing - roomId else existing + roomId
)
}
}
suspend fun clearSession() { suspend fun clearSession() {
context.dataStore.edit { it.clear() } context.dataStore.edit { it.clear() }
} }

View File

@@ -0,0 +1,45 @@
package com.example.fluffytrix.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class CustomEmoji(
val url: String,
val body: String? = null,
val usage: List<String> = listOf("emoticon"),
)
@Serializable
data class EmojiPackInfo(
@SerialName("display_name") val displayName: String? = null,
@SerialName("avatar_url") val avatarUrl: String? = null,
)
@Serializable
data class EmojiPackData(
val pack: EmojiPackInfo = EmojiPackInfo(),
val images: Map<String, CustomEmoji> = emptyMap(),
)
@Serializable
data class UserEmojiAccountData(
val packs: Map<String, EmojiPackData> = emptyMap(),
)
// UI-facing (resolved URLs)
data class EmojiEntry(
val shortcode: String,
val mxcUrl: String,
val displayName: String,
val resolvedUrl: String,
)
data class EmojiPack(
val packId: String,
val displayName: String,
val avatarMxcUrl: String?,
val emojis: List<EmojiEntry>,
val isRoomPack: Boolean = false,
val roomId: String? = null,
)

View File

@@ -0,0 +1,37 @@
package com.example.fluffytrix.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class GiphyResponse(
val data: List<GiphyResult> = emptyList(),
)
@Serializable
data class GiphyResult(
val id: String,
val title: String = "",
val images: GiphyImages = GiphyImages(),
) {
val previewUrl: String get() = images.fixedWidthDownsampled.url
.ifBlank { images.fixedHeight.url }
val fullUrl: String get() = images.original.url
.ifBlank { images.fixedHeight.url }
val previewWidth: Int get() = images.fixedWidthDownsampled.width.toIntOrNull() ?: 200
val previewHeight: Int get() = images.fixedWidthDownsampled.height.toIntOrNull() ?: 200
}
@Serializable
data class GiphyImages(
@SerialName("fixed_height") val fixedHeight: GiphyImageEntry = GiphyImageEntry(),
@SerialName("fixed_width_downsampled") val fixedWidthDownsampled: GiphyImageEntry = GiphyImageEntry(),
val original: GiphyImageEntry = GiphyImageEntry(),
)
@Serializable
data class GiphyImageEntry(
val url: String = "",
val width: String = "200",
val height: String = "200",
)

View File

@@ -0,0 +1,85 @@
package com.example.fluffytrix.data.repository
import com.example.fluffytrix.data.MxcUrlHelper
import com.example.fluffytrix.data.model.CustomEmoji
import com.example.fluffytrix.data.model.EmojiEntry
import com.example.fluffytrix.data.model.EmojiPack
import com.example.fluffytrix.data.model.EmojiPackData
import com.example.fluffytrix.data.model.EmojiPackInfo
import com.example.fluffytrix.data.model.UserEmojiAccountData
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
private val json = Json { ignoreUnknownKeys = true }
class EmojiPackRepository(
private val authRepository: AuthRepository,
) {
private val baseUrl: String
get() = try {
authRepository.getClient()?.session()?.homeserverUrl?.trimEnd('/') ?: ""
} catch (_: Exception) { "" }
suspend fun loadUserPacks(): List<EmojiPack> {
val client = authRepository.getClient() ?: return emptyList()
return try {
val raw = client.accountData("im.ponies.user_emojis") ?: return emptyList()
val data = json.decodeFromString<UserEmojiAccountData>(raw)
data.packs.map { (packId, packData) ->
packData.toEmojiPack(packId, baseUrl)
}
} catch (e: Exception) {
android.util.Log.e("EmojiPackRepo", "Failed to load user packs", e)
emptyList()
}
}
suspend fun saveUserPacks(packs: List<EmojiPack>) {
val client = authRepository.getClient() ?: return
try {
val packsMap = packs.associate { pack ->
pack.packId to EmojiPackData(
pack = EmojiPackInfo(displayName = pack.displayName, avatarUrl = pack.avatarMxcUrl),
images = pack.emojis.associate { entry ->
entry.shortcode to CustomEmoji(url = entry.mxcUrl, body = entry.displayName.ifBlank { null })
},
)
}
val data = UserEmojiAccountData(packs = packsMap)
client.setAccountData("im.ponies.user_emojis", json.encodeToString(data))
} catch (e: Exception) {
android.util.Log.e("EmojiPackRepo", "Failed to save user packs", e)
}
}
suspend fun uploadImage(mimeType: String, data: ByteArray): String? {
val client = authRepository.getClient() ?: return null
return try {
client.uploadMedia(mimeType, data, null)
} catch (e: Exception) {
android.util.Log.e("EmojiPackRepo", "Failed to upload emoji image", e)
null
}
}
suspend fun loadAllPacks(roomId: String? = null): List<EmojiPack> {
return loadUserPacks()
}
}
private fun EmojiPackData.toEmojiPack(packId: String, baseUrl: String): EmojiPack {
val emojis = images.map { (shortcode, emoji) ->
EmojiEntry(
shortcode = shortcode,
mxcUrl = emoji.url,
displayName = emoji.body ?: shortcode,
resolvedUrl = MxcUrlHelper.mxcToDownloadUrl(baseUrl, emoji.url) ?: emoji.url,
)
}
return EmojiPack(
packId = packId,
displayName = pack.displayName ?: packId,
avatarMxcUrl = pack.avatarUrl,
emojis = emojis,
)
}

View File

@@ -0,0 +1,48 @@
package com.example.fluffytrix.data.repository
import android.util.Log
import com.example.fluffytrix.data.model.GiphyResponse
import com.example.fluffytrix.data.model.GiphyResult
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import okhttp3.Request
class GifRepository {
private val client = OkHttpClient()
private val json = Json { ignoreUnknownKeys = true }
private val baseUrl = "https://api.giphy.com/v1/gifs"
fun trending(apiKey: String, limit: Int = 24): List<GiphyResult> {
val url = "$baseUrl/trending?api_key=$apiKey&limit=$limit&rating=g"
return fetch(url)
}
fun search(apiKey: String, query: String, limit: Int = 24): List<GiphyResult> {
val encoded = java.net.URLEncoder.encode(query, "UTF-8")
val url = "$baseUrl/search?api_key=$apiKey&q=$encoded&limit=$limit&rating=g"
return fetch(url)
}
private fun fetch(url: String): List<GiphyResult> {
Log.d("GifRepository", "Fetching: $url")
return try {
val request = Request.Builder().url(url).build()
val response = client.newCall(request).execute()
Log.d("GifRepository", "Response code: ${response.code}")
val body = response.body?.string() ?: run {
Log.w("GifRepository", "Empty body")
return emptyList()
}
if (!response.isSuccessful) {
Log.e("GifRepository", "Error response: $body")
return emptyList()
}
val results = json.decodeFromString<GiphyResponse>(body).data
Log.d("GifRepository", "Parsed ${results.size} results")
results
} catch (e: Exception) {
Log.e("GifRepository", "Fetch failed", e)
emptyList()
}
}
}

View File

@@ -3,6 +3,8 @@ package com.example.fluffytrix.di
import android.app.Application import android.app.Application
import com.example.fluffytrix.data.local.PreferencesManager import com.example.fluffytrix.data.local.PreferencesManager
import com.example.fluffytrix.data.repository.AuthRepository import com.example.fluffytrix.data.repository.AuthRepository
import com.example.fluffytrix.data.repository.EmojiPackRepository
import com.example.fluffytrix.push.PushRegistrationManager
import com.example.fluffytrix.ui.screens.login.LoginViewModel import com.example.fluffytrix.ui.screens.login.LoginViewModel
import com.example.fluffytrix.ui.screens.main.MainViewModel import com.example.fluffytrix.ui.screens.main.MainViewModel
import com.example.fluffytrix.ui.screens.verification.VerificationViewModel import com.example.fluffytrix.ui.screens.verification.VerificationViewModel
@@ -11,7 +13,7 @@ import org.koin.core.module.dsl.viewModel
import org.koin.dsl.module import org.koin.dsl.module
val appModule = module { val appModule = module {
viewModel { MainViewModel(androidApplication(), get(), get()) } viewModel { MainViewModel(androidApplication(), get(), get(), get()) }
viewModel { LoginViewModel(get()) } viewModel { LoginViewModel(get()) }
viewModel { VerificationViewModel(get()) } viewModel { VerificationViewModel(get()) }
} }
@@ -19,4 +21,6 @@ val appModule = module {
val dataModule = module { val dataModule = module {
single { PreferencesManager(get()) } single { PreferencesManager(get()) }
single { AuthRepository(get(), get()) } single { AuthRepository(get(), get()) }
single { EmojiPackRepository(get()) }
single { PushRegistrationManager(get()) }
} }

View File

@@ -0,0 +1,17 @@
package com.example.fluffytrix.push
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
object DeepLinkState {
private val _pendingRoomId = MutableStateFlow<String?>(null)
val pendingRoomId: StateFlow<String?> = _pendingRoomId
fun set(roomId: String) {
_pendingRoomId.value = roomId
}
fun clear() {
_pendingRoomId.value = null
}
}

View File

@@ -0,0 +1,64 @@
package com.example.fluffytrix.push
import android.util.Log
import com.example.fluffytrix.data.local.PreferencesManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.unifiedpush.android.connector.FailedReason
import org.unifiedpush.android.connector.PushService
import org.unifiedpush.android.connector.data.PushEndpoint
import org.unifiedpush.android.connector.data.PushMessage
private const val TAG = "PushReceiver"
class FluffytrixPushService : PushService(), KoinComponent {
private val preferencesManager: PreferencesManager by inject()
private val pushRegistrationManager: PushRegistrationManager by inject()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onDestroy() {
super.onDestroy()
scope.cancel()
}
override fun onMessage(message: PushMessage, instance: String) {
Log.d(TAG, "Push message received")
scope.launch {
NotificationHelper.show(this@FluffytrixPushService, message.content, preferencesManager)
}
}
override fun onNewEndpoint(endpoint: PushEndpoint, instance: String) {
Log.i(TAG, "New UP endpoint: ${endpoint.url}")
scope.launch {
preferencesManager.setUpEndpoint(endpoint.url)
try {
pushRegistrationManager.registerPusher(endpoint.url)
} catch (e: Exception) {
Log.e(TAG, "Failed to register pusher: ${e.message}")
}
}
}
override fun onRegistrationFailed(reason: FailedReason, instance: String) {
Log.w(TAG, "UP registration failed: $reason")
}
override fun onUnregistered(instance: String) {
Log.i(TAG, "UP unregistered")
scope.launch {
val endpoint = preferencesManager.upEndpoint.first()
if (endpoint != null) {
pushRegistrationManager.unregisterPusher(endpoint)
}
preferencesManager.setUpEndpoint(null)
}
}
}

View File

@@ -0,0 +1,85 @@
package com.example.fluffytrix.push
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import com.example.fluffytrix.MainActivity
import com.example.fluffytrix.R
import com.example.fluffytrix.data.local.PreferencesManager
import kotlinx.coroutines.flow.first
import org.json.JSONObject
object NotificationHelper {
private const val CHANNEL_ID = "messages"
private const val CHANNEL_NAME = "Messages"
fun ensureChannel(context: Context) {
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (nm.getNotificationChannel(CHANNEL_ID) == null) {
val channel = NotificationChannel(
CHANNEL_ID,
CHANNEL_NAME,
NotificationManager.IMPORTANCE_HIGH,
)
nm.createNotificationChannel(channel)
}
}
suspend fun show(context: Context, payload: ByteArray, preferencesManager: PreferencesManager) {
val notificationsEnabled = preferencesManager.notificationsEnabled.first()
if (!notificationsEnabled) return
val mutedRooms = preferencesManager.mutedRooms.first()
val json = try {
JSONObject(String(payload))
} catch (_: Exception) {
return
}
val notification = json.optJSONObject("notification") ?: return
val roomId = notification.optString("room_id").takeIf { it.isNotBlank() } ?: return
if (roomId in mutedRooms) return
val cachedNames = preferencesManager.roomNameCache.first()
val roomName = notification.optString("room_name").ifBlank {
cachedNames[roomId] ?: roomId
}
val sender = notification.optString("sender_display_name").ifBlank {
notification.optString("sender").ifBlank { "Someone" }
}
val content = notification.optJSONObject("counts")?.let {
val msgs = it.optInt("unread", 0)
if (msgs > 0) "$msgs unread message${if (msgs > 1) "s" else ""}" else null
} ?: "$sender sent a message"
ensureChannel(context)
val tapIntent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra("roomId", roomId)
}
val pendingIntent = PendingIntent.getActivity(
context,
roomId.hashCode(),
tapIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val notif = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(roomName)
.setContentText(content)
.setAutoCancel(true)
.setContentIntent(pendingIntent)
.build()
nm.notify(roomId.hashCode(), notif)
}
}

View File

@@ -0,0 +1,89 @@
package com.example.fluffytrix.push
import android.util.Log
import com.example.fluffytrix.data.local.PreferencesManager
import kotlinx.coroutines.flow.first
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
private const val TAG = "PushReg"
class PushRegistrationManager(
private val preferencesManager: PreferencesManager,
) {
private val httpClient = OkHttpClient()
suspend fun registerPusher(endpoint: String) {
val homeserver = preferencesManager.homeserverUrl.first() ?: run {
Log.w(TAG, "No homeserver URL — skipping pusher registration")
return
}
val accessToken = preferencesManager.accessToken.first() ?: run {
Log.w(TAG, "No access token — skipping pusher registration")
return
}
val deviceId = preferencesManager.deviceId.first() ?: "fluffytrix"
val userId = preferencesManager.userId.first() ?: "unknown"
val body = JSONObject().apply {
put("app_id", "com.example.fluffytrix")
put("app_display_name", "Fluffytrix")
put("device_display_name", deviceId)
put("kind", "http")
put("lang", "en")
put("pushkey", endpoint)
put("data", JSONObject().apply {
put("url", run {
val parsed = java.net.URL(endpoint)
"${parsed.protocol}://${parsed.host}${if (parsed.port != -1) ":${parsed.port}" else ""}/_matrix/push/v1/notify"
})
})
}.toString()
val url = "${homeserver.trimEnd('/')}/_matrix/client/v3/pushers/set"
val request = Request.Builder()
.url(url)
.addHeader("Authorization", "Bearer $accessToken")
.post(body.toRequestBody("application/json".toMediaType()))
.build()
httpClient.newCall(request).execute().use { response ->
val responseBody = response.body?.string()
if (response.isSuccessful) {
Log.i(TAG, "Pusher registered successfully for $userId")
} else {
val msg = "HTTP ${response.code}: $responseBody"
Log.w(TAG, "Pusher registration failed: $msg")
throw Exception(msg)
}
}
}
suspend fun unregisterPusher(endpoint: String) {
val homeserver = preferencesManager.homeserverUrl.first() ?: return
val accessToken = preferencesManager.accessToken.first() ?: return
val body = JSONObject().apply {
put("app_id", "com.example.fluffytrix")
put("kind", null)
put("pushkey", endpoint)
}.toString()
val url = "${homeserver.trimEnd('/')}/_matrix/client/v3/pushers/set"
val request = Request.Builder()
.url(url)
.addHeader("Authorization", "Bearer $accessToken")
.post(body.toRequestBody("application/json".toMediaType()))
.build()
try {
httpClient.newCall(request).execute().close()
Log.i(TAG, "Pusher unregistered")
} catch (e: Exception) {
Log.e(TAG, "Pusher unregister error", e)
}
}
}

View File

@@ -27,6 +27,9 @@ import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import com.example.fluffytrix.data.local.PreferencesManager import com.example.fluffytrix.data.local.PreferencesManager
import com.example.fluffytrix.data.repository.AuthRepository import com.example.fluffytrix.data.repository.AuthRepository
import androidx.navigation.NavType
import androidx.navigation.navArgument
import com.example.fluffytrix.ui.screens.emoji.EmojiPackManagementScreen
import com.example.fluffytrix.ui.screens.login.LoginScreen import com.example.fluffytrix.ui.screens.login.LoginScreen
import com.example.fluffytrix.ui.screens.main.MainScreen import com.example.fluffytrix.ui.screens.main.MainScreen
import com.example.fluffytrix.ui.screens.settings.SettingsScreen import com.example.fluffytrix.ui.screens.settings.SettingsScreen
@@ -150,6 +153,16 @@ fun FluffytrixNavigation() {
}, },
) )
} }
composable(
route = Screen.EmojiPackManagement.routeWithArgs,
arguments = listOf(navArgument("roomId") { type = NavType.StringType; nullable = true; defaultValue = null }),
) { backStackEntry ->
val roomId = backStackEntry.arguments?.getString("roomId")
EmojiPackManagementScreen(
roomId = roomId,
onBack = { navController.popBackStack() },
)
}
composable(Screen.Settings.route) { composable(Screen.Settings.route) {
SettingsScreen( SettingsScreen(
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
@@ -158,6 +171,9 @@ fun FluffytrixNavigation() {
popUpTo(0) { inclusive = true } popUpTo(0) { inclusive = true }
} }
}, },
onEmojiPackManagement = {
navController.navigate(Screen.EmojiPackManagement.route())
},
) )
} }
} }

View File

@@ -5,4 +5,8 @@ sealed class Screen(val route: String) {
data object Verification : Screen("verification") data object Verification : Screen("verification")
data object Main : Screen("main") data object Main : Screen("main")
data object Settings : Screen("settings") data object Settings : Screen("settings")
data object EmojiPackManagement : Screen("emoji_packs") {
fun route(roomId: String? = null) = if (roomId != null) "emoji_packs?roomId=$roomId" else "emoji_packs"
const val routeWithArgs = "emoji_packs?roomId={roomId}"
}
} }

View File

@@ -0,0 +1,379 @@
package com.example.fluffytrix.ui.screens.emoji
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
import com.example.fluffytrix.data.model.EmojiEntry
import com.example.fluffytrix.data.model.EmojiPack
import com.example.fluffytrix.ui.screens.main.MainViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EmojiPackManagementScreen(
roomId: String?,
onBack: () -> Unit,
viewModel: MainViewModel = koinViewModel(),
) {
val emojiPacks by viewModel.emojiPacks.collectAsStateWithLifecycle()
val userPacks = emojiPacks.filter { !it.isRoomPack }
var showCreateDialog by remember { mutableStateOf(false) }
var editingPack by remember { mutableStateOf<EmojiPack?>(null) }
if (showCreateDialog) {
CreatePackDialog(
onDismiss = { showCreateDialog = false },
onCreate = { name ->
val newPack = EmojiPack(
packId = name.lowercase().replace(Regex("[^a-z0-9_]"), "_"),
displayName = name,
avatarMxcUrl = null,
emojis = emptyList(),
)
val updated = userPacks + newPack
viewModel.saveUserEmojiPacks(updated)
showCreateDialog = false
},
)
}
editingPack?.let { pack ->
EditPackDialog(
pack = pack,
viewModel = viewModel,
onDismiss = { editingPack = null },
onSave = { updatedPack ->
val updated = userPacks.map { if (it.packId == updatedPack.packId) updatedPack else it }
viewModel.saveUserEmojiPacks(updated)
editingPack = null
},
)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Emoji Packs") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
}
},
)
},
floatingActionButton = {
ExtendedFloatingActionButton(
onClick = { showCreateDialog = true },
icon = { Icon(Icons.Default.Add, null) },
text = { Text("Create Pack") },
)
},
) { padding ->
if (userPacks.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize().padding(padding),
contentAlignment = Alignment.Center,
) {
Text(
"No emoji packs yet. Create one to get started.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize().padding(padding),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(userPacks, key = { it.packId }) { pack ->
PackRow(
pack = pack,
onEdit = { editingPack = pack },
onDelete = {
val updated = userPacks.filter { it.packId != pack.packId }
viewModel.saveUserEmojiPacks(updated)
},
)
}
}
}
}
}
@Composable
private fun PackRow(
pack: EmojiPack,
onEdit: () -> Unit,
onDelete: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onEdit() }
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (pack.avatarMxcUrl != null) {
AsyncImage(
model = pack.avatarMxcUrl,
contentDescription = null,
modifier = Modifier.size(40.dp).clip(CircleShape),
contentScale = ContentScale.Crop,
)
} else {
Box(
modifier = Modifier.size(40.dp).clip(CircleShape)
.then(Modifier.fillMaxWidth()),
contentAlignment = Alignment.Center,
) {
Text(
pack.displayName.take(1).uppercase(),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
}
}
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(pack.displayName, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium)
Text("${pack.emojis.size} emojis", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
IconButton(onClick = onDelete) {
Icon(Icons.Default.Delete, "Delete", tint = MaterialTheme.colorScheme.error)
}
}
}
@Composable
private fun CreatePackDialog(onDismiss: () -> Unit, onCreate: (String) -> Unit) {
var name by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Create Emoji Pack") },
text = {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Pack name") },
singleLine = true,
)
},
confirmButton = {
TextButton(onClick = { if (name.isNotBlank()) onCreate(name.trim()) }, enabled = name.isNotBlank()) {
Text("Create")
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
},
)
}
@Composable
private fun EditPackDialog(
pack: EmojiPack,
viewModel: MainViewModel,
onDismiss: () -> Unit,
onSave: (EmojiPack) -> Unit,
) {
var emojis by remember { mutableStateOf(pack.emojis) }
var addShortcode by remember { mutableStateOf("") }
var pendingImageUri by remember { mutableStateOf<Uri?>(null) }
var isUploading by remember { mutableStateOf(false) }
val context = LocalContext.current
val scope = rememberCoroutineScope()
val imagePicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
if (uri != null) pendingImageUri = uri
}
// When we have a pending URI and a shortcode, prompt for shortcode first
if (pendingImageUri != null) {
AlertDialog(
onDismissRequest = { pendingImageUri = null },
title = { Text("Set Shortcode") },
text = {
OutlinedTextField(
value = addShortcode,
onValueChange = { addShortcode = it },
label = { Text("Shortcode (e.g. blobcat)") },
singleLine = true,
)
},
confirmButton = {
TextButton(
onClick = {
val uri = pendingImageUri ?: return@TextButton
val code = addShortcode.trim().trimStart(':').trimEnd(':')
if (code.isBlank()) return@TextButton
isUploading = true
scope.launch(Dispatchers.IO) {
try {
val bytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
val mime = context.contentResolver.getType(uri) ?: "image/png"
if (bytes != null) {
val mxcUrl = viewModel.uploadEmojiImage(mime, bytes)
if (mxcUrl != null) {
val entry = EmojiEntry(
shortcode = code,
mxcUrl = mxcUrl,
displayName = code,
resolvedUrl = mxcUrl,
)
withContext(Dispatchers.Main) {
emojis = emojis + entry
}
}
}
} finally {
withContext(Dispatchers.Main) {
isUploading = false
pendingImageUri = null
addShortcode = ""
}
}
}
},
enabled = addShortcode.isNotBlank() && !isUploading,
) {
Text(if (isUploading) "Uploading…" else "Add")
}
},
dismissButton = {
TextButton(onClick = { pendingImageUri = null; addShortcode = "" }) { Text("Cancel") }
},
)
}
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Scaffold(
topBar = {
@OptIn(ExperimentalMaterial3Api::class)
TopAppBar(
title = { Text(pack.displayName) },
navigationIcon = {
IconButton(onClick = onDismiss) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Close")
}
},
actions = {
TextButton(onClick = { onSave(pack.copy(emojis = emojis)) }) {
Text("Save")
}
},
)
},
floatingActionButton = {
ExtendedFloatingActionButton(
onClick = { imagePicker.launch("image/*") },
icon = { Icon(Icons.Default.Add, null) },
text = { Text("Add Emoji") },
)
},
) { padding ->
if (emojis.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize().padding(padding),
contentAlignment = Alignment.Center,
) {
Text("No emojis yet. Tap Add Emoji to upload one.", color = MaterialTheme.colorScheme.onSurfaceVariant)
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize().padding(padding),
contentPadding = PaddingValues(16.dp),
) {
items(emojis, key = { it.shortcode }) { entry ->
EmojiEntryRow(
entry = entry,
onDelete = { emojis = emojis.filter { it.shortcode != entry.shortcode } },
)
HorizontalDivider()
}
}
}
}
}
}
@Composable
private fun EmojiEntryRow(entry: EmojiEntry, onDelete: () -> Unit) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
AsyncImage(
model = entry.resolvedUrl,
contentDescription = entry.shortcode,
modifier = Modifier.size(40.dp).clip(RoundedCornerShape(4.dp)),
contentScale = ContentScale.Fit,
)
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(":${entry.shortcode}:", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
if (entry.displayName != entry.shortcode) {
Text(entry.displayName, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
IconButton(onClick = onDelete) {
Icon(Icons.Default.Delete, "Delete", tint = MaterialTheme.colorScheme.error)
}
}
}

View File

@@ -9,30 +9,50 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.ime
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import androidx.compose.runtime.Immutable
import com.example.fluffytrix.data.local.PreferencesManager import com.example.fluffytrix.data.local.PreferencesManager
import com.example.fluffytrix.push.DeepLinkState
import com.example.fluffytrix.ui.screens.main.components.ChannelList import com.example.fluffytrix.ui.screens.main.components.ChannelList
import com.example.fluffytrix.ui.screens.main.components.MemberList import com.example.fluffytrix.ui.screens.main.components.MemberList
import com.example.fluffytrix.ui.screens.main.components.MessageTimeline import com.example.fluffytrix.ui.screens.main.components.MessageTimeline
import com.example.fluffytrix.ui.screens.main.components.SpaceList import com.example.fluffytrix.ui.screens.main.components.SpaceList
import com.example.fluffytrix.ui.screens.main.components.UserProfileSheet
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject import org.koin.compose.koinInject
@Immutable
private data class ProfileSheetState(val userId: String, val displayName: String, val avatarUrl: String?)
@Composable @Composable
fun MainScreen( fun MainScreen(
onLogout: () -> Unit, onLogout: () -> Unit,
onSettingsClick: () -> Unit = {}, onSettingsClick: () -> Unit = {},
viewModel: MainViewModel = koinViewModel(), viewModel: MainViewModel = koinViewModel(),
) { ) {
val pendingDeepLinkRoom by DeepLinkState.pendingRoomId.collectAsStateWithLifecycle()
LaunchedEffect(pendingDeepLinkRoom) {
val roomId = pendingDeepLinkRoom ?: return@LaunchedEffect
viewModel.openRoom(roomId)
}
val spaces by viewModel.spaces.collectAsStateWithLifecycle() val spaces by viewModel.spaces.collectAsStateWithLifecycle()
val channels by viewModel.channels.collectAsStateWithLifecycle() val channels by viewModel.channels.collectAsStateWithLifecycle()
val selectedSpace by viewModel.selectedSpace.collectAsStateWithLifecycle() val selectedSpace by viewModel.selectedSpace.collectAsStateWithLifecycle()
@@ -50,13 +70,40 @@ fun MainScreen(
val expandedThreadRooms by viewModel.expandedThreadRooms.collectAsStateWithLifecycle() val expandedThreadRooms by viewModel.expandedThreadRooms.collectAsStateWithLifecycle()
val selectedThread by viewModel.selectedThread.collectAsStateWithLifecycle() val selectedThread by viewModel.selectedThread.collectAsStateWithLifecycle()
val threadMessages by viewModel.threadMessages.collectAsStateWithLifecycle() val threadMessages by viewModel.threadMessages.collectAsStateWithLifecycle()
val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle()
val replyingTo by viewModel.replyingTo.collectAsStateWithLifecycle()
val editingMessage by viewModel.editingMessage.collectAsStateWithLifecycle()
val emojiPacks by viewModel.emojiPacks.collectAsStateWithLifecycle()
val listState = viewModel.channelListState val listState = viewModel.channelListState
val preferencesManager: PreferencesManager = koinInject() val preferencesManager: PreferencesManager = koinInject()
val hideSpacesWhenClosed by preferencesManager.hideSpacesWhenClosed.collectAsStateWithLifecycle(initialValue = false) val hideSpacesWhenClosed by preferencesManager.hideSpacesWhenClosed.collectAsStateWithLifecycle(initialValue = false)
// Back button: close thread first, then open channel list var profileSheet by remember { mutableStateOf<ProfileSheetState?>(null) }
BackHandler(enabled = selectedThread != null || (selectedChannel != null && !showChannelList)) {
if (selectedThread != null) { profileSheet?.let { sheet ->
UserProfileSheet(
userId = sheet.userId,
displayName = sheet.displayName,
avatarUrl = sheet.avatarUrl,
currentUserId = currentUserId,
onDismiss = { profileSheet = null },
onStartDm = {
profileSheet = null
viewModel.startDm(sheet.userId)
},
)
}
val keyboardController = LocalSoftwareKeyboardController.current
val imeInsets = WindowInsets.ime
val density = LocalDensity.current
val isKeyboardVisible = imeInsets.getBottom(density) > 0
// Back button: dismiss keyboard first, then close thread, then open channel list
BackHandler(enabled = isKeyboardVisible || selectedThread != null || (selectedChannel != null && !showChannelList)) {
if (isKeyboardVisible) {
keyboardController?.hide()
} else if (selectedThread != null) {
viewModel.closeThread() viewModel.closeThread()
} else { } else {
viewModel.toggleChannelList() viewModel.toggleChannelList()
@@ -69,12 +116,13 @@ fun MainScreen(
.fillMaxSize() .fillMaxSize()
.pointerInput(showChannelList) { .pointerInput(showChannelList) {
if (!showChannelList) { if (!showChannelList) {
val thresholdPx = 60.dp.toPx()
var totalDrag = 0f var totalDrag = 0f
detectHorizontalDragGestures( detectHorizontalDragGestures(
onDragStart = { totalDrag = 0f }, onDragStart = { totalDrag = 0f },
onHorizontalDrag = { _, dragAmount -> onHorizontalDrag = { _, dragAmount ->
totalDrag += dragAmount totalDrag += dragAmount
if (totalDrag > 60f) { if (totalDrag > thresholdPx) {
viewModel.toggleChannelList() viewModel.toggleChannelList()
totalDrag = 0f totalDrag = 0f
} }
@@ -105,6 +153,7 @@ fun MainScreen(
onToggleMemberList = { viewModel.toggleMemberList() }, onToggleMemberList = { viewModel.toggleMemberList() },
onSendMessage = { viewModel.sendMessage(it) }, onSendMessage = { viewModel.sendMessage(it) },
onSendFiles = { uris, caption -> viewModel.sendFiles(uris, caption) }, onSendFiles = { uris, caption -> viewModel.sendFiles(uris, caption) },
onSendGif = { url -> viewModel.sendGif(url) },
onLoadMore = { viewModel.loadMoreMessages() }, onLoadMore = { viewModel.loadMoreMessages() },
unreadMarkerIndex = unreadMarkerIndex, unreadMarkerIndex = unreadMarkerIndex,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
@@ -128,12 +177,29 @@ fun MainScreen(
} }
} }
}, },
currentUserId = currentUserId,
replyingTo = replyingTo,
editingMessage = editingMessage,
onSetReplyingTo = { viewModel.setReplyingTo(it) },
onSetEditingMessage = { viewModel.setEditingMessage(it) },
onSendReply = { body, eventId -> viewModel.sendReply(body, eventId) },
onSendThreadReply = { body, eventId -> viewModel.sendThreadReply(body, eventId) },
onEditMessage = { eventId, body -> viewModel.editMessage(eventId, body) },
onEditThreadMessage = { eventId, body -> viewModel.editThreadMessage(eventId, body) },
onSendReaction = { eventId, emoji -> viewModel.sendReaction(eventId, emoji) },
onSendThreadReaction = { eventId, emoji -> viewModel.sendThreadReaction(eventId, emoji) },
emojiPacks = emojiPacks,
onStartDm = { userId -> viewModel.startDm(userId) },
memberNames = remember(members) { members.associate { it.userId to it.displayName } },
) )
AnimatedVisibility(visible = showMemberList) { AnimatedVisibility(visible = showMemberList) {
MemberList( MemberList(
members = members, members = members,
contentPadding = padding, contentPadding = padding,
onMemberClick = { member ->
profileSheet = ProfileSheetState(member.userId, member.displayName, member.avatarUrl)
},
) )
} }
} }

View File

@@ -11,9 +11,12 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.example.fluffytrix.data.MxcUrlHelper import com.example.fluffytrix.data.MxcUrlHelper
import com.example.fluffytrix.data.local.PreferencesManager import com.example.fluffytrix.data.local.PreferencesManager
import com.example.fluffytrix.data.model.EmojiPack
import com.example.fluffytrix.data.repository.AuthRepository import com.example.fluffytrix.data.repository.AuthRepository
import com.example.fluffytrix.data.repository.EmojiPackRepository
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.coroutines.async import kotlinx.coroutines.async
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
@@ -21,14 +24,19 @@ import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.CreateRoomParameters
import org.matrix.rustcomponents.sdk.EditedContent
import org.matrix.rustcomponents.sdk.EventOrTransactionId import org.matrix.rustcomponents.sdk.EventOrTransactionId
import org.matrix.rustcomponents.sdk.Membership import org.matrix.rustcomponents.sdk.Membership
import org.matrix.rustcomponents.sdk.MembershipState import org.matrix.rustcomponents.sdk.MembershipState
import org.matrix.rustcomponents.sdk.MessageType import org.matrix.rustcomponents.sdk.MessageType
import org.matrix.rustcomponents.sdk.MsgLikeKind import org.matrix.rustcomponents.sdk.MsgLikeKind
import org.matrix.rustcomponents.sdk.ProfileDetails import org.matrix.rustcomponents.sdk.ProfileDetails
import org.matrix.rustcomponents.sdk.RoomPreset
import org.matrix.rustcomponents.sdk.RoomVisibility
import org.matrix.rustcomponents.sdk.SyncService import org.matrix.rustcomponents.sdk.SyncService
import org.matrix.rustcomponents.sdk.DateDividerMode import org.matrix.rustcomponents.sdk.DateDividerMode
import org.matrix.rustcomponents.sdk.TimelineConfiguration import org.matrix.rustcomponents.sdk.TimelineConfiguration
@@ -41,6 +49,7 @@ import org.matrix.rustcomponents.sdk.TimelineListener
import org.matrix.rustcomponents.sdk.UploadParameters import org.matrix.rustcomponents.sdk.UploadParameters
import org.matrix.rustcomponents.sdk.UploadSource import org.matrix.rustcomponents.sdk.UploadSource
import org.json.JSONObject import org.json.JSONObject
import org.matrix.rustcomponents.sdk.messageEventContentFromHtml
import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown
enum class UnreadStatus { NONE, UNREAD, MENTIONED } enum class UnreadStatus { NONE, UNREAD, MENTIONED }
@@ -60,11 +69,13 @@ data class ChannelItem(
val unreadStatus: UnreadStatus = UnreadStatus.NONE, val unreadStatus: UnreadStatus = UnreadStatus.NONE,
) )
data class InlineEmoji(val shortcode: String, val mxcUrl: String, val resolvedUrl: String)
sealed interface MessageContent { sealed interface MessageContent {
data class Text(val body: String, val urls: List<String> = emptyList()) : MessageContent data class Text(val body: String, val urls: List<String> = emptyList(), val inlineEmojis: List<InlineEmoji> = emptyList()) : MessageContent
data class Image(val body: String, val url: String, val width: Int? = null, val height: Int? = null) : MessageContent data class Image(val body: String, val url: String, val sourceJson: String? = null, val mimeType: String? = null, val width: Int? = null, val height: Int? = null) : MessageContent
data class Gif(val body: String, val url: String, val width: Int? = null, val height: Int? = null) : MessageContent data class Gif(val body: String, val url: String, val sourceJson: String? = null, val mimeType: String? = null, val width: Int? = null, val height: Int? = null) : MessageContent
data class Video(val body: String, val url: String? = null, val thumbnailUrl: String? = null, val width: Int? = null, val height: Int? = null) : MessageContent data class Video(val body: String, val url: String? = null, val sourceJson: String? = null, val mimeType: String? = null, val thumbnailUrl: String? = null, val thumbnailSourceJson: String? = null, val width: Int? = null, val height: Int? = null) : MessageContent
data class File(val body: String, val fileName: String? = null, val size: Long? = null) : MessageContent data class File(val body: String, val fileName: String? = null, val size: Long? = null) : MessageContent
} }
@@ -87,6 +98,7 @@ data class MessageItem(
val timestamp: Long, val timestamp: Long,
val replyTo: ReplyInfo? = null, val replyTo: ReplyInfo? = null,
val threadRootEventId: String? = null, val threadRootEventId: String? = null,
val reactions: Map<String, List<String>> = emptyMap(), // emoji -> list of full Matrix user IDs
) )
data class ThreadItem( data class ThreadItem(
@@ -114,8 +126,12 @@ class MainViewModel(
private val application: Application, private val application: Application,
private val authRepository: AuthRepository, private val authRepository: AuthRepository,
private val preferencesManager: PreferencesManager, private val preferencesManager: PreferencesManager,
private val emojiPackRepository: EmojiPackRepository,
) : ViewModel() { ) : ViewModel() {
private val httpClient = okhttp3.OkHttpClient()
private var lastSavedNameCache: Map<String, String> = emptyMap()
private val _spaces = MutableStateFlow<List<SpaceItem>>(emptyList()) private val _spaces = MutableStateFlow<List<SpaceItem>>(emptyList())
val spaces: StateFlow<List<SpaceItem>> = _spaces val spaces: StateFlow<List<SpaceItem>> = _spaces
@@ -128,6 +144,36 @@ class MainViewModel(
private val _selectedChannel = MutableStateFlow<String?>(null) private val _selectedChannel = MutableStateFlow<String?>(null)
val selectedChannel: StateFlow<String?> = _selectedChannel val selectedChannel: StateFlow<String?> = _selectedChannel
fun openRoom(roomId: String, persist: Boolean = true) {
viewModelScope.launch {
// Wait for space children map if not yet populated (preload runs after first room load)
val spaceMap = if (_spaceChildrenMap.value.isNotEmpty()) {
_spaceChildrenMap.value
} else {
withTimeoutOrNull(6000) {
_spaceChildrenMap.first { it.isNotEmpty() }
} ?: _spaceChildrenMap.value
}
val parentSpaceId = spaceMap.entries.firstOrNull { (_, rooms) -> roomId in rooms }?.key
if (parentSpaceId != null) {
_selectedSpace.value = parentSpaceId
loadSpaceChildren(parentSpaceId)
} else {
_selectedSpace.value = null
val cached = cachedOrphanIds
if (cached != null) _spaceChildren.value = cached
else { _spaceChildren.value = emptySet(); loadOrphanRooms() }
}
selectChannel(roomId, persist = persist)
_showChannelList.value = false
if (com.example.fluffytrix.push.DeepLinkState.pendingRoomId.value == roomId) {
com.example.fluffytrix.push.DeepLinkState.clear()
}
}
}
private val _showChannelList = MutableStateFlow(true) private val _showChannelList = MutableStateFlow(true)
val showChannelList: StateFlow<Boolean> = _showChannelList val showChannelList: StateFlow<Boolean> = _showChannelList
@@ -185,6 +231,18 @@ class MainViewModel(
private val _homeUnreadStatus = MutableStateFlow(UnreadStatus.NONE) private val _homeUnreadStatus = MutableStateFlow(UnreadStatus.NONE)
val homeUnreadStatus: StateFlow<UnreadStatus> = _homeUnreadStatus val homeUnreadStatus: StateFlow<UnreadStatus> = _homeUnreadStatus
private val _currentUserId = MutableStateFlow<String?>(null)
val currentUserId: StateFlow<String?> = _currentUserId
private val _replyingTo = MutableStateFlow<MessageItem?>(null)
val replyingTo: StateFlow<MessageItem?> = _replyingTo
private val _editingMessage = MutableStateFlow<MessageItem?>(null)
val editingMessage: StateFlow<MessageItem?> = _editingMessage
private val _emojiPacks = MutableStateFlow<List<EmojiPack>>(emptyList())
val emojiPacks: StateFlow<List<EmojiPack>> = _emojiPacks
private val _spaceChildrenMap = MutableStateFlow<Map<String, Set<String>>>(emptyMap()) private val _spaceChildrenMap = MutableStateFlow<Map<String, Set<String>>>(emptyMap())
private val _channelSections = MutableStateFlow<List<ChannelSection>>(emptyList()) private val _channelSections = MutableStateFlow<List<ChannelSection>>(emptyList())
@@ -271,7 +329,9 @@ class MainViewModel(
init { init {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
syncService = authRepository.getOrStartSync() syncService = authRepository.getOrStartSync()
try { _currentUserId.value = authRepository.getClient()?.userId() } catch (_: Exception) {}
loadRooms() loadRooms()
loadEmojiPacks()
} }
observeSelectedChannel() observeSelectedChannel()
observeSpaceFiltering() observeSpaceFiltering()
@@ -372,7 +432,7 @@ class MainViewModel(
} }
} }
_allChannelRooms.value = joinedRooms val channelItems = joinedRooms
.filter { try { !it.isSpace() } catch (_: Exception) { true } } .filter { try { !it.isSpace() } catch (_: Exception) { true } }
.map { room -> .map { room ->
ChannelItem( ChannelItem(
@@ -382,12 +442,25 @@ class MainViewModel(
avatarUrl = avatarUrl(room.avatarUrl()), avatarUrl = avatarUrl(room.avatarUrl()),
) )
} }
_allChannelRooms.value = channelItems
val newNameCache = channelItems.associate { it.id to it.name }
if (newNameCache != lastSavedNameCache) {
lastSavedNameCache = newNameCache
preferencesManager.saveRoomNameCache(newNameCache)
}
updateSpaceUnreadStatus() updateSpaceUnreadStatus()
// On first load, compute orphan rooms and preload space children for unread dots // On first load, restore last opened room and compute orphan rooms
if (!orphanRoomsLoaded && _selectedSpace.value == null) { if (!orphanRoomsLoaded && _selectedSpace.value == null) {
orphanRoomsLoaded = true orphanRoomsLoaded = true
// Restore last opened room (skip if a deep-link room is already pending)
if (com.example.fluffytrix.push.DeepLinkState.pendingRoomId.value == null && _selectedChannel.value == null) {
val lastRoom = preferencesManager.lastOpenedRoom.first()
if (lastRoom != null && channelItems.any { it.id == lastRoom }) {
openRoom(lastRoom, persist = false)
}
}
loadOrphanRooms() loadOrphanRooms()
} }
if (!spaceChildrenPreloaded && _spaces.value.isNotEmpty()) { if (!spaceChildrenPreloaded && _spaces.value.isNotEmpty()) {
@@ -500,9 +573,12 @@ class MainViewModel(
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
try { try {
val room = client.getRoom(roomId) val room = client.getRoom(roomId)
val name = room?.displayName() ?: roomId val sdkName = room?.displayName()
// Don't overwrite a real seeded name with a generic SDK placeholder
val isGeneric = sdkName == null || sdkName == "Empty Room" || sdkName.startsWith("!")
val name = if (isGeneric) channelNameCache[roomId] ?: sdkName ?: roomId else sdkName
channelNameCache[roomId] = name channelNameCache[roomId] = name
_channelName.value = name if (_selectedChannel.value == roomId) _channelName.value = name
} catch (_: Exception) { } } catch (_: Exception) { }
} }
} }
@@ -529,6 +605,7 @@ class MainViewModel(
override fun onUpdate(diff: List<TimelineDiff>) { override fun onUpdate(diff: List<TimelineDiff>) {
viewModelScope.launch(Dispatchers.Default) { viewModelScope.launch(Dispatchers.Default) {
mutex.withLock { mutex.withLock {
if (_selectedChannel.value != roomId) return@withLock
for (d in diff) { for (d in diff) {
when (d) { when (d) {
is TimelineDiff.Reset -> { is TimelineDiff.Reset -> {
@@ -545,11 +622,46 @@ class MainViewModel(
} }
is TimelineDiff.Set -> { is TimelineDiff.Set -> {
val idx = d.index.toInt() val idx = d.index.toInt()
if (idx in sdkItems.indices) sdkItems[idx] = d.value if (idx in sdkItems.indices) {
// Remove the old item from cache (e.g. TransactionId → EventId transition on send)
val old = sdkItems[idx]
sdkItems[idx] = d.value
val oldEvent = old.asEvent()
if (oldEvent != null) {
val oldId = when (val eot = oldEvent.eventOrTransactionId) {
is EventOrTransactionId.EventId -> eot.eventId
is EventOrTransactionId.TransactionId -> eot.transactionId
}
if (ids.remove(oldId)) {
val ri = cached.indexOfFirst { it.eventId == oldId }
if (ri >= 0) cached.removeAt(ri)
// Also remove from thread cache if it was a thread reply
threadMessageCache[roomId]?.values?.forEach { list ->
list.removeAll { it.eventId == oldId }
}
}
}
}
} }
is TimelineDiff.Remove -> { is TimelineDiff.Remove -> {
val idx = d.index.toInt() val idx = d.index.toInt()
if (idx in sdkItems.indices) sdkItems.removeAt(idx) if (idx in sdkItems.indices) {
val removed = sdkItems.removeAt(idx)
val removedEvent = removed.asEvent()
if (removedEvent != null) {
val removedId = when (val eot = removedEvent.eventOrTransactionId) {
is EventOrTransactionId.EventId -> eot.eventId
is EventOrTransactionId.TransactionId -> eot.transactionId
}
if (ids.remove(removedId)) {
val ri = cached.indexOfFirst { it.eventId == removedId }
if (ri >= 0) cached.removeAt(ri)
threadMessageCache[roomId]?.values?.forEach { list ->
list.removeAll { it.eventId == removedId }
}
}
}
}
} }
is TimelineDiff.Truncate -> { is TimelineDiff.Truncate -> {
val len = d.length.toInt() val len = d.length.toInt()
@@ -699,22 +811,29 @@ class MainViewModel(
senderAvatar = avatarUrl(profile.avatarUrl) senderAvatar = avatarUrl(profile.avatarUrl)
} }
else -> { else -> {
senderName = senderNameCache[localpart] ?: localpart senderName = senderNameCache[sender] ?: localpart
senderAvatar = senderAvatarCache[localpart] senderAvatar = senderAvatarCache[sender]
} }
} }
senderNameCache[localpart] = senderName senderNameCache[sender] = senderName
if (senderAvatar != null) senderAvatarCache[localpart] = senderAvatar if (senderAvatar != null) senderAvatarCache[sender] = senderAvatar
val reactions = if (content is TimelineItemContent.MsgLike) {
content.content.reactions
.filter { it.senders.isNotEmpty() }
.associate { reaction -> reaction.key to reaction.senders.map { it.senderId } }
} else emptyMap()
val msg = MessageItem( val msg = MessageItem(
eventId = eventId, eventId = eventId,
senderId = localpart, senderId = sender,
senderName = senderName, senderName = senderName,
senderAvatarUrl = senderAvatar, senderAvatarUrl = senderAvatar,
content = msgContent, content = msgContent,
timestamp = eventItem.timestamp.toLong(), timestamp = eventItem.timestamp.toLong(),
replyTo = replyInfo, replyTo = replyInfo,
threadRootEventId = threadRootId, threadRootEventId = threadRootId,
reactions = reactions,
) )
ids.add(eventId) ids.add(eventId)
@@ -751,6 +870,7 @@ class MainViewModel(
MessageContent.Text( MessageContent.Text(
body = text, body = text,
urls = urlRegex.findAll(text).map { it.value }.toList(), urls = urlRegex.findAll(text).map { it.value }.toList(),
inlineEmojis = parseInlineEmojis(rawJson),
) )
} }
is MessageType.Notice -> { is MessageType.Notice -> {
@@ -758,25 +878,31 @@ class MainViewModel(
MessageContent.Text( MessageContent.Text(
body = text, body = text,
urls = urlRegex.findAll(text).map { it.value }.toList(), urls = urlRegex.findAll(text).map { it.value }.toList(),
inlineEmojis = parseInlineEmojis(rawJson),
) )
} }
is MessageType.Emote -> { is MessageType.Emote -> {
MessageContent.Text(body = "* ${msgType.content.body}") MessageContent.Text(body = "* ${msgType.content.body}", inlineEmojis = parseInlineEmojis(rawJson))
} }
is MessageType.Image -> { is MessageType.Image -> {
val c = msgType.content val c = msgType.content
val mxcUrl = c.source.url() val mxcUrl = c.source.url()
val url = MxcUrlHelper.mxcToDownloadUrl(baseUrl, mxcUrl) ?: mxcUrl val url = MxcUrlHelper.mxcToDownloadUrl(baseUrl, mxcUrl) ?: mxcUrl
val sourceJson = try { c.source.toJson() } catch (_: Exception) { null }
val info = c.info val info = c.info
val isGif = info?.mimetype == "image/gif" || info?.isAnimated == true val isGif = info?.mimetype == "image/gif" || info?.isAnimated == true
if (isGif) MessageContent.Gif( if (isGif) MessageContent.Gif(
body = c.filename, body = c.filename,
url = url, url = url,
sourceJson = sourceJson,
mimeType = info?.mimetype ?: "image/gif",
width = info?.width?.toInt(), width = info?.width?.toInt(),
height = info?.height?.toInt(), height = info?.height?.toInt(),
) else MessageContent.Image( ) else MessageContent.Image(
body = c.filename, body = c.filename,
url = url, url = url,
sourceJson = sourceJson,
mimeType = info?.mimetype ?: "image/*",
width = info?.width?.toInt(), width = info?.width?.toInt(),
height = info?.height?.toInt(), height = info?.height?.toInt(),
) )
@@ -785,6 +911,7 @@ class MainViewModel(
val c = msgType.content val c = msgType.content
val mxcUrl = c.source.url() val mxcUrl = c.source.url()
val url = MxcUrlHelper.mxcToDownloadUrl(baseUrl, mxcUrl) ?: mxcUrl val url = MxcUrlHelper.mxcToDownloadUrl(baseUrl, mxcUrl) ?: mxcUrl
val sourceJson = try { c.source.toJson() } catch (_: Exception) { null }
val info = c.info val info = c.info
// Detect Discord bridge GIFs: fi.mau.gif in raw event, or tenor/giphy body URL // Detect Discord bridge GIFs: fi.mau.gif in raw event, or tenor/giphy body URL
val isGifVideo = (rawJson != null && rawJson.contains("\"fi.mau.gif\"")) || val isGifVideo = (rawJson != null && rawJson.contains("\"fi.mau.gif\"")) ||
@@ -794,16 +921,22 @@ class MainViewModel(
MessageContent.Gif( MessageContent.Gif(
body = c.filename, body = c.filename,
url = url, url = url,
sourceJson = sourceJson,
mimeType = info?.mimetype ?: "video/mp4",
width = info?.width?.toInt(), width = info?.width?.toInt(),
height = info?.height?.toInt(), height = info?.height?.toInt(),
) )
} else { } else {
val thumbMxc = info?.thumbnailSource?.url() val thumbMxc = info?.thumbnailSource?.url()
val thumbnailUrl = MxcUrlHelper.mxcToThumbnailUrl(baseUrl, thumbMxc, 300) ?: url val thumbnailUrl = MxcUrlHelper.mxcToThumbnailUrl(baseUrl, thumbMxc, 300) ?: url
val thumbnailSourceJson = try { info?.thumbnailSource?.toJson() } catch (_: Exception) { null }
MessageContent.Video( MessageContent.Video(
body = c.filename, body = c.filename,
url = url, url = url,
sourceJson = sourceJson,
mimeType = info?.mimetype ?: "video/*",
thumbnailUrl = thumbnailUrl, thumbnailUrl = thumbnailUrl,
thumbnailSourceJson = thumbnailSourceJson,
width = info?.width?.toInt(), width = info?.width?.toInt(),
height = info?.height?.toInt(), height = info?.height?.toInt(),
) )
@@ -856,9 +989,8 @@ class MainViewModel(
} }
memberCache[roomId] = memberList memberCache[roomId] = memberList
memberList.forEach { m -> memberList.forEach { m ->
val localpart = m.userId.removePrefix("@").substringBefore(":") senderAvatarCache[m.userId] = avatarUrl(m.avatarUrl)
senderAvatarCache[localpart] = avatarUrl(m.avatarUrl) senderNameCache[m.userId] = m.displayName
senderNameCache[localpart] = m.displayName
} }
// Backfill avatars into cached messages // Backfill avatars into cached messages
messageCache[roomId]?.let { cached -> messageCache[roomId]?.let { cached ->
@@ -889,15 +1021,66 @@ class MainViewModel(
} }
} }
fun loadEmojiPacks() {
viewModelScope.launch(Dispatchers.IO) {
_emojiPacks.value = emojiPackRepository.loadAllPacks(_selectedChannel.value)
}
}
fun saveUserEmojiPacks(packs: List<EmojiPack>) {
viewModelScope.launch(Dispatchers.IO) {
emojiPackRepository.saveUserPacks(packs)
loadEmojiPacks()
}
}
suspend fun uploadEmojiImage(mimeType: String, data: ByteArray): String? {
return emojiPackRepository.uploadImage(mimeType, data)
}
private fun parseInlineEmojis(rawJson: String?): List<InlineEmoji> {
rawJson ?: return emptyList()
return try {
val json = org.json.JSONObject(rawJson)
val formattedBody = json.optJSONObject("content")?.optString("formatted_body") ?: return emptyList()
val regex = Regex("""<img[^>]+data-mx-emoticon[^>]+src="(mxc://[^"]+)"[^>]+alt="([^"]+)"[^>]*/?>""")
val packs = _emojiPacks.value
regex.findAll(formattedBody).mapNotNull { match ->
val mxcUrl = match.groupValues[1]
val alt = match.groupValues[2]
val resolvedUrl = MxcUrlHelper.mxcToDownloadUrl(baseUrl, mxcUrl) ?: mxcUrl
InlineEmoji(shortcode = alt, mxcUrl = mxcUrl, resolvedUrl = resolvedUrl)
}.toList()
} catch (_: Exception) { emptyList() }
}
fun sendMessage(body: String) { fun sendMessage(body: String) {
val timeline = activeTimeline ?: return val timeline = activeTimeline ?: return
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
try { try {
timeline.send(messageEventContentFromMarkdown(body)) val content = buildMessageContent(body)
timeline.send(content)
} catch (_: Exception) { } } catch (_: Exception) { }
} }
} }
private fun buildMessageContent(body: String): org.matrix.rustcomponents.sdk.RoomMessageEventContentWithoutRelation {
val packs = _emojiPacks.value
if (packs.isEmpty()) return messageEventContentFromMarkdown(body)
// Build a map of shortcode -> emoji entry across all packs
val emojiMap = packs.flatMap { it.emojis }.associateBy { ":${it.shortcode}:" }
val found = emojiMap.keys.filter { body.contains(it) }
if (found.isEmpty()) return messageEventContentFromMarkdown(body)
// Build formatted body with inline emoji img tags
var formattedBody = body
for (key in found) {
val entry = emojiMap[key] ?: continue
val imgTag = """<img data-mx-emoticon src="${entry.mxcUrl}" alt=":${entry.shortcode}:" height="32" />"""
formattedBody = formattedBody.replace(key, imgTag)
}
return messageEventContentFromHtml(body, formattedBody)
}
fun sendFiles(uris: List<Uri>, caption: String?) { fun sendFiles(uris: List<Uri>, caption: String?) {
val timeline = activeTimeline ?: return val timeline = activeTimeline ?: return
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
@@ -942,6 +1125,41 @@ class MainViewModel(
} }
} }
fun sendGif(url: String) {
val timeline = activeTimeline ?: return
viewModelScope.launch(Dispatchers.IO) {
try {
android.util.Log.d("SendGif", "Downloading $url")
val response = httpClient.newCall(okhttp3.Request.Builder().url(url).build()).execute()
android.util.Log.d("SendGif", "Response: ${response.code}")
val bytes = response.body?.bytes() ?: run {
android.util.Log.e("SendGif", "Empty body")
return@launch
}
android.util.Log.d("SendGif", "Downloaded ${bytes.size} bytes, sending…")
val params = UploadParameters(
source = UploadSource.Data(bytes, "giphy.gif"),
caption = null,
formattedCaption = null,
mentions = null,
inReplyTo = null,
)
timeline.sendFile(
params = params,
fileInfo = org.matrix.rustcomponents.sdk.FileInfo(
mimetype = "image/gif",
size = bytes.size.toULong(),
thumbnailInfo = null,
thumbnailSource = null,
),
)
android.util.Log.d("SendGif", "Sent!")
} catch (e: Exception) {
android.util.Log.e("SendGif", "Failed", e)
}
}
}
fun selectHome() { fun selectHome() {
if (_selectedSpace.value == null) { if (_selectedSpace.value == null) {
_showChannelList.value = !_showChannelList.value _showChannelList.value = !_showChannelList.value
@@ -1178,7 +1396,7 @@ class MainViewModel(
} }
} }
fun selectChannel(channelId: String) { fun selectChannel(channelId: String, persist: Boolean = true) {
// Place unread marker: in descending list (newest=0), marker at index (count-1) // Place unread marker: in descending list (newest=0), marker at index (count-1)
// means it appears visually above the block of unread messages // means it appears visually above the block of unread messages
val unreadCount = _roomUnreadCount.value[channelId]?.toInt() ?: 0 val unreadCount = _roomUnreadCount.value[channelId]?.toInt() ?: 0
@@ -1186,6 +1404,7 @@ class MainViewModel(
_unreadMarkerIndex.value = if (unreadCount > 0) unreadCount - 1 else -1 _unreadMarkerIndex.value = if (unreadCount > 0) unreadCount - 1 else -1
_selectedChannel.value = channelId _selectedChannel.value = channelId
if (persist) viewModelScope.launch { preferencesManager.setLastOpenedRoom(channelId) }
if (_roomUnreadStatus.value.containsKey(channelId)) { if (_roomUnreadStatus.value.containsKey(channelId)) {
_roomUnreadStatus.value = _roomUnreadStatus.value - channelId _roomUnreadStatus.value = _roomUnreadStatus.value - channelId
_roomUnreadCount.value = _roomUnreadCount.value - channelId _roomUnreadCount.value = _roomUnreadCount.value - channelId
@@ -1434,7 +1653,7 @@ class MainViewModel(
val threadTimeline = activeThreadTimeline ?: return@withLock val threadTimeline = activeThreadTimeline ?: return@withLock
try { try {
// Thread-focused timeline sends automatically include the m.thread relation // Thread-focused timeline sends automatically include the m.thread relation
threadTimeline.send(messageEventContentFromMarkdown(body)) threadTimeline.send(buildMessageContent(body))
} catch (e: Exception) { } catch (e: Exception) {
android.util.Log.e("MainVM", "Failed to send thread message", e) android.util.Log.e("MainVM", "Failed to send thread message", e)
} }
@@ -1442,8 +1661,125 @@ class MainViewModel(
} }
} }
fun setReplyingTo(message: MessageItem?) { _replyingTo.value = message }
fun setEditingMessage(message: MessageItem?) { _editingMessage.value = message }
fun editMessage(eventId: String, newBody: String) {
val timeline = activeTimeline ?: return
viewModelScope.launch(Dispatchers.IO) {
try {
timeline.edit(
EventOrTransactionId.EventId(eventId),
EditedContent.RoomMessage(messageEventContentFromMarkdown(newBody)),
)
} catch (_: Exception) {}
_editingMessage.value = null
}
}
fun editThreadMessage(eventId: String, newBody: String) {
viewModelScope.launch(Dispatchers.IO) {
threadTimelineMutex.withLock {
val t = activeThreadTimeline ?: return@withLock
try {
t.edit(
EventOrTransactionId.EventId(eventId),
EditedContent.RoomMessage(messageEventContentFromMarkdown(newBody)),
)
} catch (_: Exception) {}
_editingMessage.value = null
}
}
}
fun sendReaction(eventId: String, emoji: String) {
val timeline = activeTimeline ?: return
viewModelScope.launch(Dispatchers.IO) {
try { timeline.toggleReaction(EventOrTransactionId.EventId(eventId), emoji) } catch (_: Exception) {}
}
}
fun sendThreadReaction(eventId: String, emoji: String) {
viewModelScope.launch(Dispatchers.IO) {
threadTimelineMutex.withLock {
val t = activeThreadTimeline ?: return@withLock
try { t.toggleReaction(EventOrTransactionId.EventId(eventId), emoji) } catch (_: Exception) {}
}
}
}
fun sendReply(body: String, replyToEventId: String) {
val timeline = activeTimeline ?: return
viewModelScope.launch(Dispatchers.IO) {
try { timeline.sendReply(messageEventContentFromMarkdown(body), replyToEventId) } catch (_: Exception) {}
_replyingTo.value = null
}
}
fun sendThreadReply(body: String, replyToEventId: String) {
viewModelScope.launch(Dispatchers.IO) {
threadTimelineMutex.withLock {
val t = activeThreadTimeline ?: return@withLock
try { t.sendReply(messageEventContentFromMarkdown(body), replyToEventId) } catch (_: Exception) {}
_replyingTo.value = null
}
}
}
fun startDm(userId: String) {
val client = authRepository.getClient() ?: return
val normalizedUserId = if (userId.startsWith("@")) userId else "@$userId"
android.util.Log.d("MainVM", "startDm called with userId='$userId' normalized='$normalizedUserId'")
viewModelScope.launch(Dispatchers.IO) {
try {
// Reuse existing DM if one already exists
val existingRoom = try { client.getDmRoom(normalizedUserId) } catch (_: Exception) { null }
val roomId: String
if (existingRoom != null) {
roomId = existingRoom.id()
} else {
val newRoomId = client.createRoom(
CreateRoomParameters(
name = null,
topic = null,
isEncrypted = true,
isDirect = true,
visibility = RoomVisibility.Private,
preset = RoomPreset.TRUSTED_PRIVATE_CHAT,
invite = listOf(normalizedUserId),
avatar = null,
powerLevelContentOverride = null,
joinRuleOverride = null,
historyVisibilityOverride = null,
canonicalAlias = null,
isSpace = false,
)
)
roomId = newRoomId
// Seed the channel name from the invited user's profile so it
// shows their name immediately rather than "Empty Room"
val displayName = try {
client.getProfile(normalizedUserId).displayName?.takeIf { it.isNotBlank() }
?: normalizedUserId.removePrefix("@").substringBefore(":")
} catch (_: Exception) {
normalizedUserId.removePrefix("@").substringBefore(":")
}
channelNameCache[roomId] = displayName
}
withContext(kotlinx.coroutines.Dispatchers.Main) {
selectChannel(roomId)
_showChannelList.value = false
}
} catch (e: Exception) {
android.util.Log.e("MainVM", "startDm failed", e)
}
}
}
fun logout() { fun logout() {
ProcessLifecycleOwner.get().lifecycle.removeObserver(lifecycleObserver) ProcessLifecycleOwner.get().lifecycle.removeObserver(lifecycleObserver)
com.example.fluffytrix.push.DeepLinkState.clear()
viewModelScope.launch { viewModelScope.launch {
try { syncService?.stop() } catch (_: Exception) { } try { syncService?.stop() } catch (_: Exception) { }
authRepository.logout() authRepository.logout()

View File

@@ -171,12 +171,13 @@ fun ChannelList(
.fillMaxHeight() .fillMaxHeight()
.background(MaterialTheme.colorScheme.surface) .background(MaterialTheme.colorScheme.surface)
.pointerInput(Unit) { .pointerInput(Unit) {
val thresholdPx = 60.dp.toPx()
var totalDrag = 0f var totalDrag = 0f
detectHorizontalDragGestures( detectHorizontalDragGestures(
onDragStart = { totalDrag = 0f }, onDragStart = { totalDrag = 0f },
onHorizontalDrag = { _, dragAmount -> onHorizontalDrag = { _, dragAmount ->
totalDrag += dragAmount totalDrag += dragAmount
if (totalDrag < -60f) { if (totalDrag < -thresholdPx) {
onDismiss() onDismiss()
totalDrag = 0f totalDrag = 0f
} }

View File

@@ -1,6 +1,7 @@
package com.example.fluffytrix.ui.screens.main.components package com.example.fluffytrix.ui.screens.main.components
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -31,6 +32,7 @@ import com.example.fluffytrix.ui.screens.main.MemberItem
fun MemberList( fun MemberList(
members: List<MemberItem>, members: List<MemberItem>,
contentPadding: PaddingValues = PaddingValues(), contentPadding: PaddingValues = PaddingValues(),
onMemberClick: (MemberItem) -> Unit = {},
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
@@ -56,6 +58,7 @@ fun MemberList(
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { onMemberClick(member) }
.padding(horizontal = 8.dp, vertical = 4.dp), .padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp), horizontalArrangement = Arrangement.spacedBy(10.dp),

View File

@@ -101,8 +101,8 @@ fun SpaceList(
.size(8.dp) .size(8.dp)
.align(Alignment.TopEnd) .align(Alignment.TopEnd)
.background( .background(
if (homeUnreadStatus == UnreadStatus.MENTIONED) androidx.compose.ui.graphics.Color.Red if (homeUnreadStatus == UnreadStatus.MENTIONED) MaterialTheme.colorScheme.error
else androidx.compose.ui.graphics.Color.Gray, else MaterialTheme.colorScheme.primary,
CircleShape, CircleShape,
), ),
) )
@@ -147,8 +147,8 @@ fun SpaceList(
.size(8.dp) .size(8.dp)
.align(Alignment.TopEnd) .align(Alignment.TopEnd)
.background( .background(
if (space.unreadStatus == UnreadStatus.MENTIONED) androidx.compose.ui.graphics.Color.Red if (space.unreadStatus == UnreadStatus.MENTIONED) MaterialTheme.colorScheme.error
else androidx.compose.ui.graphics.Color.Gray, else MaterialTheme.colorScheme.primary,
CircleShape, CircleShape,
), ),
) )

View File

@@ -8,11 +8,14 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos
import androidx.compose.foundation.clickable
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@@ -20,36 +23,61 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import org.unifiedpush.android.connector.UnifiedPush
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.example.fluffytrix.data.local.PreferencesManager import com.example.fluffytrix.data.local.PreferencesManager
import com.example.fluffytrix.push.PushRegistrationManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.compose.koinInject import org.koin.compose.koinInject
private sealed interface GiphyKeyStatus {
data object Idle : GiphyKeyStatus
data object Testing : GiphyKeyStatus
data object Invalid : GiphyKeyStatus
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(
onBack: () -> Unit, onBack: () -> Unit,
onLogout: () -> Unit, onLogout: () -> Unit,
onEmojiPackManagement: () -> Unit = {},
) { ) {
val preferencesManager: PreferencesManager = koinInject() val preferencesManager: PreferencesManager = koinInject()
val userId by preferencesManager.userId.collectAsState(initial = null) val pushRegistrationManager: PushRegistrationManager = koinInject()
val homeserver by preferencesManager.homeserverUrl.collectAsState(initial = null) val userId by preferencesManager.userId.collectAsStateWithLifecycle(initialValue = null)
val deviceId by preferencesManager.deviceId.collectAsState(initial = null) val homeserver by preferencesManager.homeserverUrl.collectAsStateWithLifecycle(initialValue = null)
val hideSpacesWhenClosed by preferencesManager.hideSpacesWhenClosed.collectAsState(initial = false) val deviceId by preferencesManager.deviceId.collectAsStateWithLifecycle(initialValue = null)
val hideSpacesWhenClosed by preferencesManager.hideSpacesWhenClosed.collectAsStateWithLifecycle(initialValue = false)
val notificationsEnabled by preferencesManager.notificationsEnabled.collectAsStateWithLifecycle(initialValue = true)
val upEndpoint by preferencesManager.upEndpoint.collectAsStateWithLifecycle(initialValue = null)
val savedGiphyKey by preferencesManager.tenorApiKey.collectAsStateWithLifecycle(initialValue = "")
var giphyKeyInput by remember { mutableStateOf("") }
var giphyKeyStatus by remember { mutableStateOf<GiphyKeyStatus>(GiphyKeyStatus.Idle) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val context = LocalContext.current val context = LocalContext.current
var upDistributor by remember { mutableStateOf(UnifiedPush.getSavedDistributor(context) ?: "") }
var showDistributorPicker by remember { mutableStateOf(false) }
var pusherRegStatus by remember { mutableStateOf("") }
val appVersion = try { val appVersion = try {
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "Unknown" context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "Unknown"
} catch (_: Exception) { } catch (_: Exception) {
@@ -109,8 +137,196 @@ fun SettingsScreen(
HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp)) HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
SectionHeader("Customization")
SettingNavRow(label = "Emoji Packs", onClick = onEmojiPackManagement)
Column(modifier = Modifier.padding(vertical = 6.dp)) {
Text("GIPHY API Key (for GIF search)", style = MaterialTheme.typography.bodyMedium)
Text(
"Get a free key at developers.giphy.com → Create App",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(6.dp))
if (savedGiphyKey.isNotBlank()) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
"Key saved (${savedGiphyKey.take(8)}…)",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
Button(
onClick = { scope.launch { preferencesManager.setTenorApiKey("") } },
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error),
) {
Text("Remove key")
}
}
} else {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedTextField(
value = giphyKeyInput,
onValueChange = {
giphyKeyInput = it
giphyKeyStatus = GiphyKeyStatus.Idle
},
placeholder = { Text("Paste API key…") },
singleLine = true,
modifier = Modifier.weight(1f),
)
Button(
onClick = {
val key = giphyKeyInput.trim()
if (key.isBlank()) return@Button
giphyKeyStatus = GiphyKeyStatus.Testing
scope.launch {
val valid = withContext(Dispatchers.IO) {
try {
val url = "https://api.giphy.com/v1/gifs/trending?api_key=$key&limit=1"
okhttp3.OkHttpClient().newCall(okhttp3.Request.Builder().url(url).build())
.execute()
.use { it.isSuccessful }
} catch (_: Exception) { false }
}
if (valid) {
preferencesManager.setTenorApiKey(key)
giphyKeyInput = ""
giphyKeyStatus = GiphyKeyStatus.Idle
} else {
giphyKeyStatus = GiphyKeyStatus.Invalid
}
}
},
enabled = giphyKeyInput.isNotBlank() && giphyKeyStatus != GiphyKeyStatus.Testing,
) {
Text(if (giphyKeyStatus == GiphyKeyStatus.Testing) "Testing…" else "Save")
}
}
when (giphyKeyStatus) {
GiphyKeyStatus.Invalid -> Text(
"Invalid API key — check and try again.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 4.dp),
)
else -> {}
}
}
}
HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
SectionHeader("Notifications") SectionHeader("Notifications")
SettingRow("Push notifications", "Enabled") SettingToggleRow(
label = "Enable notifications",
description = "Show push notifications for new messages",
checked = notificationsEnabled,
onCheckedChange = { scope.launch { preferencesManager.setNotificationsEnabled(it) } },
)
SettingRow(
label = "Push distributor",
value = upDistributor.ifBlank { "None selected" },
)
SettingRow(
label = "Endpoint",
value = upEndpoint?.let { if (it.length > 50) it.take(50) + "" else it } ?: "Not registered",
)
Button(
onClick = { showDistributorPicker = true },
modifier = Modifier.fillMaxWidth(),
) {
Text(if (upDistributor.isBlank()) "Select distributor" else "Change distributor")
}
if (upEndpoint != null) {
Button(
onClick = {
pusherRegStatus = "Registering…"
scope.launch {
try {
withContext(Dispatchers.IO) {
pushRegistrationManager.registerPusher(upEndpoint!!)
}
pusherRegStatus = "Pusher registered!"
} catch (e: Exception) {
pusherRegStatus = "Failed: ${e.message}"
}
}
},
modifier = Modifier.fillMaxWidth(),
) {
Text("Re-register pusher with homeserver")
}
}
if (pusherRegStatus.isNotBlank()) {
Text(
pusherRegStatus,
style = MaterialTheme.typography.bodySmall,
color = if (pusherRegStatus.startsWith("Failed") || pusherRegStatus.startsWith("Pusher reg failed"))
MaterialTheme.colorScheme.error
else
MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
}
if (showDistributorPicker) {
val distributors = remember(showDistributorPicker) { UnifiedPush.getDistributors(context) }
if (distributors.isEmpty()) {
androidx.compose.material3.AlertDialog(
onDismissRequest = { showDistributorPicker = false },
title = { Text("No distributor found") },
text = { Text("Install a UnifiedPush distributor app (e.g. ntfy) to enable push notifications.") },
confirmButton = {
androidx.compose.material3.TextButton(onClick = { showDistributorPicker = false }) {
Text("OK")
}
},
)
} else {
androidx.compose.material3.AlertDialog(
onDismissRequest = { showDistributorPicker = false },
title = { Text("Select distributor") },
text = {
Column {
distributors.forEach { pkg ->
val label = try {
context.packageManager.getApplicationLabel(
context.packageManager.getApplicationInfo(pkg, 0)
).toString()
} catch (_: Exception) { pkg }
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.clickable {
UnifiedPush.saveDistributor(context, pkg)
UnifiedPush.register(context)
upDistributor = pkg
showDistributorPicker = false
}
.padding(horizontal = 4.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(label, style = MaterialTheme.typography.bodyMedium)
}
}
}
},
confirmButton = {},
dismissButton = {
androidx.compose.material3.TextButton(onClick = { showDistributorPicker = false }) {
Text("Cancel")
}
},
)
}
}
HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp)) HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
@@ -143,6 +359,7 @@ private fun SettingToggleRow(
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(min = 48.dp)
.padding(vertical = 6.dp), .padding(vertical = 6.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -166,10 +383,34 @@ private fun SettingRow(label: String, value: String) {
text = label, text = label,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
) )
androidx.compose.foundation.text.selection.SelectionContainer {
Text( Text(
text = value, text = value,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@Composable
private fun SettingNavRow(label: String, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.clickable(onClick = onClick)
.padding(vertical = 14.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(text = label, style = MaterialTheme.typography.bodyMedium)
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowForwardIos,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
} }

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z" />
</vector>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="cache" path="." />
</paths>

View File

@@ -19,7 +19,9 @@ matrixRustSdk = "26.02.19"
coil = "3.2.0" coil = "3.2.0"
media3 = "1.6.0" media3 = "1.6.0"
markdownRenderer = "0.37.0" markdownRenderer = "0.37.0"
emojiPicker = "1.6.0"
kotlinxSerialization = "1.8.1" kotlinxSerialization = "1.8.1"
unifiedpush = "3.3.1"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -78,6 +80,12 @@ markdown-renderer-m3 = { group = "com.mikepenz", name = "multiplatform-markdown-
markdown-renderer-code = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-code-android", version.ref = "markdownRenderer" } markdown-renderer-code = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-code-android", version.ref = "markdownRenderer" }
markdown-renderer-coil3 = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-coil3-android", version.ref = "markdownRenderer" } markdown-renderer-coil3 = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-coil3-android", version.ref = "markdownRenderer" }
# Jetpack Emoji Picker
emoji-picker = { group = "androidx.emoji2", name = "emoji2-emojipicker", version.ref = "emojiPicker" }
# UnifiedPush
unifiedpush = { module = "org.unifiedpush.android:connector", version.ref = "unifiedpush" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }