Compare commits
5 Commits
6a87a33ea0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c6f0bc2c7 | |||
| 9114b3189e | |||
| 276d2f2615 | |||
| 8c0cbac246 | |||
| 82890d85ba |
53
.claude/agent-memory/android-bug-hunter/MEMORY.md
Normal file
53
.claude/agent-memory/android-bug-hunter/MEMORY.md
Normal 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`
|
||||||
20
.claude/agent-memory/ui-ux-reviewer/MEMORY.md
Normal file
20
.claude/agent-memory/ui-ux-reviewer/MEMORY.md
Normal 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.
|
||||||
40
.claude/agent-memory/ui-ux-reviewer/patterns.md
Normal file
40
.claude/agent-memory/ui-ux-reviewer/patterns.md
Normal 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)
|
||||||
124
.claude/agents/ui-ux-reviewer.md
Normal file
124
.claude/agents/ui-ux-reviewer.md
Normal 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.
|
||||||
13
.idea/deviceManager.xml
generated
Normal file
13
.idea/deviceManager.xml
generated
Normal 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>
|
||||||
@@ -41,7 +41,9 @@ android {
|
|||||||
}
|
}
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
|
buildConfig = true
|
||||||
}
|
}
|
||||||
|
|
||||||
packaging {
|
packaging {
|
||||||
dex {
|
dex {
|
||||||
useLegacyPackaging = true
|
useLegacyPackaging = true
|
||||||
@@ -104,6 +106,9 @@ dependencies {
|
|||||||
// Jetpack Emoji Picker
|
// Jetpack Emoji Picker
|
||||||
implementation(libs.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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ 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.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
|
||||||
@@ -21,4 +22,5 @@ val dataModule = module {
|
|||||||
single { PreferencesManager(get()) }
|
single { PreferencesManager(get()) }
|
||||||
single { AuthRepository(get(), get()) }
|
single { AuthRepository(get(), get()) }
|
||||||
single { EmojiPackRepository(get()) }
|
single { EmojiPackRepository(get()) }
|
||||||
|
single { PushRegistrationManager(get()) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -151,9 +151,6 @@ fun FluffytrixNavigation() {
|
|||||||
onSettingsClick = {
|
onSettingsClick = {
|
||||||
navController.navigate(Screen.Settings.route)
|
navController.navigate(Screen.Settings.route)
|
||||||
},
|
},
|
||||||
onEmojiPackManagement = { roomId ->
|
|
||||||
navController.navigate(Screen.EmojiPackManagement.route(roomId))
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(
|
composable(
|
||||||
@@ -174,6 +171,9 @@ fun FluffytrixNavigation() {
|
|||||||
popUpTo(0) { inclusive = true }
|
popUpTo(0) { inclusive = true }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onEmojiPackManagement = {
|
||||||
|
navController.navigate(Screen.EmojiPackManagement.route())
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,31 +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 = {},
|
||||||
onEmojiPackManagement: (String?) -> 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()
|
||||||
@@ -59,9 +78,32 @@ fun MainScreen(
|
|||||||
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()
|
||||||
@@ -74,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
|
||||||
}
|
}
|
||||||
@@ -110,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),
|
||||||
@@ -145,13 +189,17 @@ fun MainScreen(
|
|||||||
onSendReaction = { eventId, emoji -> viewModel.sendReaction(eventId, emoji) },
|
onSendReaction = { eventId, emoji -> viewModel.sendReaction(eventId, emoji) },
|
||||||
onSendThreadReaction = { eventId, emoji -> viewModel.sendThreadReaction(eventId, emoji) },
|
onSendThreadReaction = { eventId, emoji -> viewModel.sendThreadReaction(eventId, emoji) },
|
||||||
emojiPacks = emojiPacks,
|
emojiPacks = emojiPacks,
|
||||||
onOpenEmojiPackManagement = { onEmojiPackManagement(selectedChannel) },
|
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)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import com.example.fluffytrix.data.repository.AuthRepository
|
|||||||
import com.example.fluffytrix.data.repository.EmojiPackRepository
|
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
|
||||||
@@ -23,8 +24,10 @@ 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.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
|
||||||
@@ -32,6 +35,8 @@ 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
|
||||||
@@ -68,9 +73,9 @@ data class InlineEmoji(val shortcode: String, val mxcUrl: String, val resolvedUr
|
|||||||
|
|
||||||
sealed interface MessageContent {
|
sealed interface MessageContent {
|
||||||
data class Text(val body: String, val urls: List<String> = emptyList(), val inlineEmojis: List<InlineEmoji> = 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,6 +129,9 @@ class MainViewModel(
|
|||||||
private val emojiPackRepository: EmojiPackRepository,
|
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
|
||||||
|
|
||||||
@@ -136,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
|
||||||
|
|
||||||
@@ -394,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(
|
||||||
@@ -404,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()) {
|
||||||
@@ -522,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) { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -551,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 -> {
|
||||||
@@ -756,12 +811,12 @@ 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) {
|
val reactions = if (content is TimelineItemContent.MsgLike) {
|
||||||
content.content.reactions
|
content.content.reactions
|
||||||
@@ -771,7 +826,7 @@ class MainViewModel(
|
|||||||
|
|
||||||
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,
|
||||||
@@ -833,16 +888,21 @@ 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
|
||||||
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(),
|
||||||
)
|
)
|
||||||
@@ -851,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\"")) ||
|
||||||
@@ -860,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(),
|
||||||
)
|
)
|
||||||
@@ -922,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 ->
|
||||||
@@ -1059,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
|
||||||
@@ -1295,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
|
||||||
@@ -1303,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
|
||||||
@@ -1624,8 +1726,60 @@ class MainViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.imePadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
@@ -44,6 +45,8 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Path
|
import androidx.compose.ui.graphics.Path
|
||||||
@@ -74,27 +77,43 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
|||||||
import androidx.emoji2.emojipicker.EmojiPickerView
|
import androidx.emoji2.emojipicker.EmojiPickerView
|
||||||
import androidx.emoji2.emojipicker.EmojiViewItem
|
import androidx.emoji2.emojipicker.EmojiViewItem
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
import androidx.compose.foundation.lazy.grid.items
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
import androidx.compose.material.icons.filled.AttachFile
|
import androidx.compose.material.icons.filled.AttachFile
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.EmojiEmotions
|
import androidx.compose.material.icons.filled.EmojiEmotions
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||||
|
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import androidx.compose.runtime.mutableStateMapOf
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import androidx.compose.material.icons.automirrored.filled.Reply
|
import androidx.compose.material.icons.automirrored.filled.Reply
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
import androidx.compose.material3.ScrollableTabRow
|
import androidx.compose.material3.ScrollableTabRow
|
||||||
import androidx.compose.material3.Tab
|
import androidx.compose.material3.Tab
|
||||||
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
import androidx.compose.material.icons.filled.PlayCircleFilled
|
import androidx.compose.material.icons.filled.PlayCircleFilled
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.compositionLocalOf
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
import androidx.compose.ui.window.DialogProperties
|
import androidx.compose.ui.window.DialogProperties
|
||||||
@@ -107,7 +126,11 @@ import com.example.fluffytrix.data.repository.AuthRepository
|
|||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import com.example.fluffytrix.data.MxcUrlHelper
|
import com.example.fluffytrix.data.MxcUrlHelper
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
|
import org.matrix.rustcomponents.sdk.MediaSource
|
||||||
import com.example.fluffytrix.data.model.EmojiPack
|
import com.example.fluffytrix.data.model.EmojiPack
|
||||||
|
import com.example.fluffytrix.data.model.GiphyResult
|
||||||
|
import com.example.fluffytrix.data.repository.GifRepository
|
||||||
import com.example.fluffytrix.ui.screens.main.InlineEmoji
|
import com.example.fluffytrix.ui.screens.main.InlineEmoji
|
||||||
import com.example.fluffytrix.ui.screens.main.MessageContent
|
import com.example.fluffytrix.ui.screens.main.MessageContent
|
||||||
import com.example.fluffytrix.ui.screens.main.MessageItem
|
import com.example.fluffytrix.ui.screens.main.MessageItem
|
||||||
@@ -121,6 +144,9 @@ private val LocalVideoPlayer = compositionLocalOf<(String) -> Unit> { {} }
|
|||||||
private val LocalScrollToEvent = compositionLocalOf<(String) -> Unit> { {} }
|
private val LocalScrollToEvent = compositionLocalOf<(String) -> Unit> { {} }
|
||||||
private val LocalReactionHandler = compositionLocalOf<(eventId: String, emoji: String) -> Unit> { { _, _ -> } }
|
private val LocalReactionHandler = compositionLocalOf<(eventId: String, emoji: String) -> Unit> { { _, _ -> } }
|
||||||
private val LocalCurrentUserId = compositionLocalOf<String?> { null }
|
private val LocalCurrentUserId = compositionLocalOf<String?> { null }
|
||||||
|
private val LocalUserProfileHandler = compositionLocalOf<(userId: String, displayName: String, avatarUrl: String?) -> Unit> { { _, _, _ -> } }
|
||||||
|
// Map of userId -> displayName for resolving reaction sender names
|
||||||
|
private val LocalMemberNames = compositionLocalOf<Map<String, String>> { emptyMap() }
|
||||||
|
|
||||||
private val senderColors = arrayOf(
|
private val senderColors = arrayOf(
|
||||||
Color(0xFF5865F2),
|
Color(0xFF5865F2),
|
||||||
@@ -155,6 +181,7 @@ fun MessageTimeline(
|
|||||||
onToggleMemberList: () -> Unit,
|
onToggleMemberList: () -> Unit,
|
||||||
onSendMessage: (String) -> Unit,
|
onSendMessage: (String) -> Unit,
|
||||||
onSendFiles: (List<Uri>, String?) -> Unit,
|
onSendFiles: (List<Uri>, String?) -> Unit,
|
||||||
|
onSendGif: (String) -> Unit = {},
|
||||||
onLoadMore: () -> Unit = {},
|
onLoadMore: () -> Unit = {},
|
||||||
unreadMarkerIndex: Int = -1,
|
unreadMarkerIndex: Int = -1,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
@@ -178,11 +205,15 @@ fun MessageTimeline(
|
|||||||
onSendReaction: (String, String) -> Unit = { _, _ -> },
|
onSendReaction: (String, String) -> Unit = { _, _ -> },
|
||||||
onSendThreadReaction: (String, String) -> Unit = { _, _ -> },
|
onSendThreadReaction: (String, String) -> Unit = { _, _ -> },
|
||||||
emojiPacks: List<EmojiPack> = emptyList(),
|
emojiPacks: List<EmojiPack> = emptyList(),
|
||||||
onOpenEmojiPackManagement: () -> Unit = {},
|
onViewProfile: (userId: String, displayName: String, avatarUrl: String?) -> Unit = { _, _, _ -> },
|
||||||
|
onStartDm: (String) -> Unit = {},
|
||||||
|
memberNames: Map<String, String> = emptyMap(),
|
||||||
) {
|
) {
|
||||||
var fullscreenImageUrl by remember { mutableStateOf<String?>(null) }
|
var fullscreenImageUrl by remember { mutableStateOf<String?>(null) }
|
||||||
var fullscreenVideoUrl by remember { mutableStateOf<String?>(null) }
|
var fullscreenVideoUrl by remember { mutableStateOf<String?>(null) }
|
||||||
var contextMenuMessage by remember { mutableStateOf<MessageItem?>(null) }
|
var contextMenuMessage by remember { mutableStateOf<MessageItem?>(null) }
|
||||||
|
data class ProfileSheetState(val userId: String, val displayName: String, val avatarUrl: String?)
|
||||||
|
var profileSheet by remember { mutableStateOf<ProfileSheetState?>(null) }
|
||||||
|
|
||||||
if (fullscreenImageUrl != null) {
|
if (fullscreenImageUrl != null) {
|
||||||
FullscreenImageViewer(
|
FullscreenImageViewer(
|
||||||
@@ -225,6 +256,20 @@ fun MessageTimeline(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
profileSheet?.let { sheet ->
|
||||||
|
UserProfileSheet(
|
||||||
|
userId = sheet.userId,
|
||||||
|
displayName = sheet.displayName,
|
||||||
|
avatarUrl = sheet.avatarUrl,
|
||||||
|
currentUserId = currentUserId,
|
||||||
|
onDismiss = { profileSheet = null },
|
||||||
|
onStartDm = {
|
||||||
|
profileSheet = null
|
||||||
|
onStartDm(sheet.userId)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
val reactionHandler: (String, String) -> Unit = remember(selectedThread, onSendReaction, onSendThreadReaction) {
|
val reactionHandler: (String, String) -> Unit = remember(selectedThread, onSendReaction, onSendThreadReaction) {
|
||||||
{ eventId, emoji ->
|
{ eventId, emoji ->
|
||||||
if (selectedThread != null) onSendThreadReaction(eventId, emoji)
|
if (selectedThread != null) onSendThreadReaction(eventId, emoji)
|
||||||
@@ -237,6 +282,8 @@ fun MessageTimeline(
|
|||||||
LocalVideoPlayer provides { url -> fullscreenVideoUrl = url },
|
LocalVideoPlayer provides { url -> fullscreenVideoUrl = url },
|
||||||
LocalReactionHandler provides reactionHandler,
|
LocalReactionHandler provides reactionHandler,
|
||||||
LocalCurrentUserId provides currentUserId,
|
LocalCurrentUserId provides currentUserId,
|
||||||
|
LocalUserProfileHandler provides { userId, displayName, avatarUrl -> profileSheet = ProfileSheetState(userId, displayName, avatarUrl) },
|
||||||
|
LocalMemberNames provides memberNames,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
@@ -245,13 +292,14 @@ fun MessageTimeline(
|
|||||||
.padding(
|
.padding(
|
||||||
top = contentPadding.calculateTopPadding(),
|
top = contentPadding.calculateTopPadding(),
|
||||||
bottom = contentPadding.calculateBottomPadding(),
|
bottom = contentPadding.calculateBottomPadding(),
|
||||||
),
|
)
|
||||||
|
.imePadding(),
|
||||||
) {
|
) {
|
||||||
if (selectedChannel != null) {
|
if (selectedChannel != null) {
|
||||||
if (selectedThread != null) {
|
if (selectedThread != null) {
|
||||||
ThreadTopBar(selectedThreadName ?: "Thread in #${channelName ?: selectedChannel}", onCloseThread)
|
ThreadTopBar(selectedThreadName ?: "Thread in #${channelName ?: selectedChannel}", onCloseThread)
|
||||||
} else {
|
} else {
|
||||||
TopBar(channelName ?: selectedChannel, onToggleMemberList, onOpenEmojiPackManagement)
|
TopBar(channelName ?: selectedChannel, onToggleMemberList)
|
||||||
}
|
}
|
||||||
HorizontalDivider(color = MaterialTheme.colorScheme.surfaceVariant)
|
HorizontalDivider(color = MaterialTheme.colorScheme.surfaceVariant)
|
||||||
}
|
}
|
||||||
@@ -422,6 +470,7 @@ fun MessageTimeline(
|
|||||||
editingMessage = editingMessage,
|
editingMessage = editingMessage,
|
||||||
onSendMessage = activeSend,
|
onSendMessage = activeSend,
|
||||||
onSendFiles = onSendFiles,
|
onSendFiles = onSendFiles,
|
||||||
|
onSendGif = onSendGif,
|
||||||
onSendReply = { body, eventId ->
|
onSendReply = { body, eventId ->
|
||||||
if (selectedThread != null) onSendThreadReply(body, eventId)
|
if (selectedThread != null) onSendThreadReply(body, eventId)
|
||||||
else onSendReply(body, eventId)
|
else onSendReply(body, eventId)
|
||||||
@@ -438,7 +487,7 @@ fun MessageTimeline(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TopBar(name: String, onToggleMemberList: () -> Unit, onOpenEmojiPackManagement: () -> Unit = {}) {
|
private fun TopBar(name: String, onToggleMemberList: () -> Unit) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@@ -452,9 +501,6 @@ private fun TopBar(name: String, onToggleMemberList: () -> Unit, onOpenEmojiPack
|
|||||||
color = MaterialTheme.colorScheme.onBackground,
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
IconButton(onClick = onOpenEmojiPackManagement) {
|
|
||||||
Icon(Icons.Default.EmojiEmotions, "Emoji packs", tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
||||||
}
|
|
||||||
IconButton(onClick = onToggleMemberList) {
|
IconButton(onClick = onToggleMemberList) {
|
||||||
Icon(Icons.Default.People, "Toggle member list", tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
Icon(Icons.Default.People, "Toggle member list", tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
}
|
}
|
||||||
@@ -486,8 +532,42 @@ private fun ReactionRow(eventId: String, reactions: Map<String, List<String>>) {
|
|||||||
if (reactions.isEmpty()) return
|
if (reactions.isEmpty()) return
|
||||||
val onReact = LocalReactionHandler.current
|
val onReact = LocalReactionHandler.current
|
||||||
val currentUserId = LocalCurrentUserId.current
|
val currentUserId = LocalCurrentUserId.current
|
||||||
|
val memberNames = LocalMemberNames.current
|
||||||
val authRepository: AuthRepository = koinInject()
|
val authRepository: AuthRepository = koinInject()
|
||||||
val baseUrl = remember { try { authRepository.getClient()?.session()?.homeserverUrl?.trimEnd('/') ?: "" } catch (_: Exception) { "" } }
|
val baseUrl = remember { try { authRepository.getClient()?.session()?.homeserverUrl?.trimEnd('/') ?: "" } catch (_: Exception) { "" } }
|
||||||
|
var reactionDetailEmoji by remember { mutableStateOf<String?>(null) }
|
||||||
|
var reactionDetailSenders by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||||
|
|
||||||
|
if (reactionDetailEmoji != null) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { reactionDetailEmoji = null },
|
||||||
|
title = {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
if (reactionDetailEmoji!!.startsWith("mxc://")) {
|
||||||
|
val resolvedUrl = remember(reactionDetailEmoji) { MxcUrlHelper.mxcToDownloadUrl(baseUrl, reactionDetailEmoji!!) ?: reactionDetailEmoji!! }
|
||||||
|
AsyncImage(model = resolvedUrl, contentDescription = null, modifier = Modifier.size(24.dp))
|
||||||
|
} else {
|
||||||
|
Text(reactionDetailEmoji!!, fontSize = 20.sp)
|
||||||
|
}
|
||||||
|
Text("Reacted by", style = MaterialTheme.typography.titleMedium)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
reactionDetailSenders.forEach { sender ->
|
||||||
|
Text(
|
||||||
|
memberNames[sender] ?: sender.removePrefix("@").substringBefore(":"),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { reactionDetailEmoji = null }) { Text("Close") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
FlowRow(
|
FlowRow(
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
@@ -499,7 +579,13 @@ private fun ReactionRow(eventId: String, reactions: Map<String, List<String>>) {
|
|||||||
shape = RoundedCornerShape(12.dp),
|
shape = RoundedCornerShape(12.dp),
|
||||||
color = if (isMine) MaterialTheme.colorScheme.primaryContainer
|
color = if (isMine) MaterialTheme.colorScheme.primaryContainer
|
||||||
else MaterialTheme.colorScheme.surfaceVariant,
|
else MaterialTheme.colorScheme.surfaceVariant,
|
||||||
modifier = Modifier.clickable { onReact(eventId, emoji) },
|
modifier = Modifier.combinedClickable(
|
||||||
|
onClick = { onReact(eventId, emoji) },
|
||||||
|
onLongClick = {
|
||||||
|
reactionDetailEmoji = emoji
|
||||||
|
reactionDetailSenders = senders
|
||||||
|
},
|
||||||
|
),
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
@@ -533,6 +619,7 @@ private fun FullMessage(message: MessageItem, onOpenThread: (String) -> Unit = {
|
|||||||
val senderColor = remember(message.senderName) { colorForSender(message.senderName) }
|
val senderColor = remember(message.senderName) { colorForSender(message.senderName) }
|
||||||
val time = remember(message.timestamp) { formatTimestamp(message.timestamp) }
|
val time = remember(message.timestamp) { formatTimestamp(message.timestamp) }
|
||||||
val reply = message.replyTo
|
val reply = message.replyTo
|
||||||
|
val onViewProfile = LocalUserProfileHandler.current
|
||||||
|
|
||||||
Column(modifier = Modifier.combinedClickable(onClick = {}, onLongClick = { onLongPress(message) })) {
|
Column(modifier = Modifier.combinedClickable(onClick = {}, onLongClick = { onLongPress(message) })) {
|
||||||
if (reply != null) {
|
if (reply != null) {
|
||||||
@@ -543,12 +630,15 @@ private fun FullMessage(message: MessageItem, onOpenThread: (String) -> Unit = {
|
|||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = message.senderAvatarUrl,
|
model = message.senderAvatarUrl,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.size(40.dp).clip(CircleShape),
|
modifier = Modifier.size(40.dp).clip(CircleShape).clickable {
|
||||||
|
onViewProfile(message.senderId, message.senderName, message.senderAvatarUrl)
|
||||||
|
},
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.size(40.dp).clip(CircleShape).background(senderColor.copy(alpha = 0.3f)),
|
modifier = Modifier.size(40.dp).clip(CircleShape).background(senderColor.copy(alpha = 0.3f))
|
||||||
|
.clickable { onViewProfile(message.senderId, message.senderName, null) },
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
@@ -755,20 +845,44 @@ private fun InlineEmojiText(content: MessageContent.Text) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun resolveMediaUrl(
|
||||||
|
authRepository: AuthRepository,
|
||||||
|
sourceJson: String,
|
||||||
|
mimeType: String,
|
||||||
|
filename: String?,
|
||||||
|
fallbackUrl: String?,
|
||||||
|
): String? = try {
|
||||||
|
val client = authRepository.getClient() ?: return fallbackUrl
|
||||||
|
val source = MediaSource.fromJson(sourceJson)
|
||||||
|
val handle = client.getMediaFile(source, filename, mimeType, useCache = true, tempDir = null)
|
||||||
|
"file://${handle.path()}"
|
||||||
|
} catch (_: Exception) {
|
||||||
|
fallbackUrl
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ImageContent(content: MessageContent.Image) {
|
private fun ImageContent(content: MessageContent.Image) {
|
||||||
val onViewImage = LocalImageViewer.current
|
val onViewImage = LocalImageViewer.current
|
||||||
|
val authRepository: AuthRepository = koinInject()
|
||||||
val aspectRatio = if (content.width != null && content.height != null && content.height > 0)
|
val aspectRatio = if (content.width != null && content.height != null && content.height > 0)
|
||||||
content.width.toFloat() / content.height.toFloat() else null
|
content.width.toFloat() / content.height.toFloat() else null
|
||||||
|
|
||||||
|
val resolvedUrl by produceState<String?>(null, content.sourceJson, content.url) {
|
||||||
|
value = if (content.sourceJson != null) {
|
||||||
|
resolveMediaUrl(authRepository, content.sourceJson, content.mimeType ?: "image/*", content.body, content.url)
|
||||||
|
} else {
|
||||||
|
content.url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = content.url,
|
model = resolvedUrl,
|
||||||
contentDescription = content.body,
|
contentDescription = content.body,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.let { if (aspectRatio != null) it.width((300.dp * aspectRatio).coerceAtMost(400.dp)) else it.fillMaxWidth(0.6f) }
|
.let { if (aspectRatio != null) it.width((300.dp * aspectRatio).coerceAtMost(400.dp)) else it.fillMaxWidth(0.6f) }
|
||||||
.height(300.dp)
|
.height(300.dp)
|
||||||
.clip(RoundedCornerShape(8.dp))
|
.clip(RoundedCornerShape(8.dp))
|
||||||
.clickable { onViewImage(content.url) },
|
.clickable { resolvedUrl?.let { onViewImage(it) } },
|
||||||
contentScale = ContentScale.Fit,
|
contentScale = ContentScale.Fit,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -781,46 +895,95 @@ private fun GifContent(content: MessageContent.Gif) {
|
|||||||
val aspectRatio = if (content.width != null && content.height != null && content.height > 0)
|
val aspectRatio = if (content.width != null && content.height != null && content.height > 0)
|
||||||
content.width.toFloat() / content.height.toFloat() else 16f / 9f
|
content.width.toFloat() / content.height.toFloat() else 16f / 9f
|
||||||
|
|
||||||
val exoPlayer = remember(content.url) {
|
val isNativeGif = content.mimeType == "image/gif" ||
|
||||||
val token = try { authRepository.getClient()?.session()?.accessToken } catch (_: Exception) { null }
|
content.body.endsWith(".gif", ignoreCase = true) ||
|
||||||
val dataSourceFactory = DefaultHttpDataSource.Factory().apply {
|
content.url.endsWith(".gif", ignoreCase = true)
|
||||||
if (token != null) {
|
|
||||||
setDefaultRequestProperties(mapOf("Authorization" to "Bearer $token"))
|
val resolvedUrl by produceState<String?>(null, content.sourceJson, content.url) {
|
||||||
}
|
value = if (content.sourceJson != null) {
|
||||||
}
|
resolveMediaUrl(authRepository, content.sourceJson, content.mimeType ?: "video/mp4", content.body, content.url)
|
||||||
val mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory)
|
} else {
|
||||||
.createMediaSource(MediaItem.fromUri(Uri.parse(content.url)))
|
content.url
|
||||||
ExoPlayer.Builder(context).build().apply {
|
|
||||||
setMediaSource(mediaSource)
|
|
||||||
prepare()
|
|
||||||
playWhenReady = true
|
|
||||||
repeatMode = ExoPlayer.REPEAT_MODE_ALL
|
|
||||||
volume = 0f
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
DisposableEffect(content.url) {
|
if (isNativeGif) {
|
||||||
onDispose { exoPlayer.release() }
|
// Real GIF — use Coil (coil-gif handles animated playback)
|
||||||
}
|
AsyncImage(
|
||||||
|
model = resolvedUrl,
|
||||||
AndroidView(
|
contentDescription = content.body,
|
||||||
factory = { ctx ->
|
contentScale = ContentScale.Fit,
|
||||||
PlayerView(ctx).apply {
|
modifier = Modifier
|
||||||
player = exoPlayer
|
.width((200.dp * aspectRatio).coerceAtMost(400.dp))
|
||||||
useController = false
|
.height(200.dp)
|
||||||
setShowBuffering(PlayerView.SHOW_BUFFERING_NEVER)
|
.clip(RoundedCornerShape(8.dp)),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Video GIF (MP4/WebM from bridges) — use ExoPlayer
|
||||||
|
val exoPlayer = remember(resolvedUrl) {
|
||||||
|
val url = resolvedUrl ?: return@remember null
|
||||||
|
val isFileUri = url.startsWith("file://")
|
||||||
|
val mediaItem = MediaItem.fromUri(Uri.parse(url))
|
||||||
|
val mediaSource = if (isFileUri) {
|
||||||
|
ProgressiveMediaSource.Factory(androidx.media3.datasource.FileDataSource.Factory())
|
||||||
|
.createMediaSource(mediaItem)
|
||||||
|
} else {
|
||||||
|
val token = try { authRepository.getClient()?.session()?.accessToken } catch (_: Exception) { null }
|
||||||
|
val dataSourceFactory = DefaultHttpDataSource.Factory().apply {
|
||||||
|
if (token != null) setDefaultRequestProperties(mapOf("Authorization" to "Bearer $token"))
|
||||||
|
}
|
||||||
|
ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItem)
|
||||||
}
|
}
|
||||||
},
|
ExoPlayer.Builder(context).build().apply {
|
||||||
modifier = Modifier
|
setMediaSource(mediaSource)
|
||||||
.width((200.dp * aspectRatio).coerceAtMost(400.dp))
|
prepare()
|
||||||
.height(200.dp)
|
playWhenReady = true
|
||||||
.clip(RoundedCornerShape(8.dp)),
|
repeatMode = ExoPlayer.REPEAT_MODE_ALL
|
||||||
)
|
volume = 0f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(resolvedUrl) {
|
||||||
|
onDispose { exoPlayer?.release() }
|
||||||
|
}
|
||||||
|
|
||||||
|
AndroidView(
|
||||||
|
factory = { ctx ->
|
||||||
|
PlayerView(ctx).apply {
|
||||||
|
useController = false
|
||||||
|
setShowBuffering(PlayerView.SHOW_BUFFERING_NEVER)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
update = { view -> view.player = exoPlayer },
|
||||||
|
modifier = Modifier
|
||||||
|
.width((200.dp * aspectRatio).coerceAtMost(400.dp))
|
||||||
|
.height(200.dp)
|
||||||
|
.clip(RoundedCornerShape(8.dp)),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun VideoContent(content: MessageContent.Video) {
|
private fun VideoContent(content: MessageContent.Video) {
|
||||||
val onPlayVideo = LocalVideoPlayer.current
|
val onPlayVideo = LocalVideoPlayer.current
|
||||||
|
val authRepository: AuthRepository = koinInject()
|
||||||
|
|
||||||
|
val resolvedVideoUrl by produceState<String?>(null, content.sourceJson, content.url) {
|
||||||
|
value = if (content.sourceJson != null) {
|
||||||
|
resolveMediaUrl(authRepository, content.sourceJson, content.mimeType ?: "video/*", content.body, content.url)
|
||||||
|
} else {
|
||||||
|
content.url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val resolvedThumbnailUrl by produceState<String?>(null, content.thumbnailSourceJson, content.thumbnailUrl) {
|
||||||
|
value = if (content.thumbnailSourceJson != null) {
|
||||||
|
resolveMediaUrl(authRepository, content.thumbnailSourceJson, "image/*", null, content.thumbnailUrl)
|
||||||
|
} else {
|
||||||
|
content.thumbnailUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.height(200.dp)
|
.height(200.dp)
|
||||||
@@ -830,12 +993,12 @@ private fun VideoContent(content: MessageContent.Video) {
|
|||||||
mod.width((200.dp * ar).coerceAtMost(400.dp))
|
mod.width((200.dp * ar).coerceAtMost(400.dp))
|
||||||
}
|
}
|
||||||
.clip(RoundedCornerShape(8.dp))
|
.clip(RoundedCornerShape(8.dp))
|
||||||
.clickable { content.url?.let { onPlayVideo(it) } },
|
.clickable { resolvedVideoUrl?.let { onPlayVideo(it) } },
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
if (content.thumbnailUrl != null) {
|
if (resolvedThumbnailUrl != null) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = content.thumbnailUrl,
|
model = resolvedThumbnailUrl,
|
||||||
contentDescription = content.body,
|
contentDescription = content.body,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
@@ -912,14 +1075,17 @@ private fun FullscreenVideoPlayer(url: String, onDismiss: () -> Unit) {
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val authRepository: AuthRepository = koinInject()
|
val authRepository: AuthRepository = koinInject()
|
||||||
val exoPlayer = remember {
|
val exoPlayer = remember {
|
||||||
val token = try { authRepository.getClient()?.session()?.accessToken } catch (_: Exception) { null }
|
val mediaItem = MediaItem.fromUri(Uri.parse(url))
|
||||||
val dataSourceFactory = DefaultHttpDataSource.Factory().apply {
|
val mediaSource = if (url.startsWith("file://")) {
|
||||||
if (token != null) {
|
ProgressiveMediaSource.Factory(androidx.media3.datasource.FileDataSource.Factory())
|
||||||
setDefaultRequestProperties(mapOf("Authorization" to "Bearer $token"))
|
.createMediaSource(mediaItem)
|
||||||
|
} else {
|
||||||
|
val token = try { authRepository.getClient()?.session()?.accessToken } catch (_: Exception) { null }
|
||||||
|
val dataSourceFactory = DefaultHttpDataSource.Factory().apply {
|
||||||
|
if (token != null) setDefaultRequestProperties(mapOf("Authorization" to "Bearer $token"))
|
||||||
}
|
}
|
||||||
|
ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItem)
|
||||||
}
|
}
|
||||||
val mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory)
|
|
||||||
.createMediaSource(MediaItem.fromUri(Uri.parse(url)))
|
|
||||||
ExoPlayer.Builder(context).build().apply {
|
ExoPlayer.Builder(context).build().apply {
|
||||||
setMediaSource(mediaSource)
|
setMediaSource(mediaSource)
|
||||||
prepare()
|
prepare()
|
||||||
@@ -1012,12 +1178,15 @@ private fun MessageInput(
|
|||||||
channelName: String,
|
channelName: String,
|
||||||
onSendMessage: (String) -> Unit,
|
onSendMessage: (String) -> Unit,
|
||||||
onSendFiles: (List<Uri>, String?) -> Unit,
|
onSendFiles: (List<Uri>, String?) -> Unit,
|
||||||
|
onSendGif: (String) -> Unit = {},
|
||||||
replyingTo: MessageItem? = null,
|
replyingTo: MessageItem? = null,
|
||||||
editingMessage: MessageItem? = null,
|
editingMessage: MessageItem? = null,
|
||||||
onSendReply: (String, String) -> Unit = { _, _ -> },
|
onSendReply: (String, String) -> Unit = { _, _ -> },
|
||||||
onEditMessage: (String, String) -> Unit = { _, _ -> },
|
onEditMessage: (String, String) -> Unit = { _, _ -> },
|
||||||
emojiPacks: List<EmojiPack> = emptyList(),
|
emojiPacks: List<EmojiPack> = emptyList(),
|
||||||
) {
|
) {
|
||||||
|
val prefsManager: com.example.fluffytrix.data.local.PreferencesManager = koinInject()
|
||||||
|
val gifApiKey by prefsManager.tenorApiKey.collectAsState(initial = "")
|
||||||
var text by remember { mutableStateOf("") }
|
var text by remember { mutableStateOf("") }
|
||||||
var attachedUris by remember { mutableStateOf(listOf<Uri>()) }
|
var attachedUris by remember { mutableStateOf(listOf<Uri>()) }
|
||||||
var showEmojiPackPicker by remember { mutableStateOf(false) }
|
var showEmojiPackPicker by remember { mutableStateOf(false) }
|
||||||
@@ -1063,8 +1232,12 @@ private fun MessageInput(
|
|||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val tabs = emojiPacks.map { it.displayName } + "Unicode"
|
val inputScope = rememberCoroutineScope()
|
||||||
|
val inputContext = LocalContext.current
|
||||||
var selectedTab by remember { mutableStateOf(0) }
|
var selectedTab by remember { mutableStateOf(0) }
|
||||||
|
val tabs = if (emojiPacks.isNotEmpty()) listOf("Custom Emojis", "GIFs", "Unicode") else listOf("GIFs", "Unicode")
|
||||||
|
val customTabIndex = if (emojiPacks.isNotEmpty()) 0 else -1
|
||||||
|
val gifTabIndex = if (emojiPacks.isNotEmpty()) 1 else 0
|
||||||
ScrollableTabRow(selectedTabIndex = selectedTab) {
|
ScrollableTabRow(selectedTabIndex = selectedTab) {
|
||||||
tabs.forEachIndexed { index, title ->
|
tabs.forEachIndexed { index, title ->
|
||||||
Tab(
|
Tab(
|
||||||
@@ -1074,27 +1247,36 @@ private fun MessageInput(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (selectedTab < emojiPacks.size) {
|
when {
|
||||||
CustomEmojiGrid(
|
selectedTab == customTabIndex -> CollapsableCustomEmojiPacks(
|
||||||
pack = emojiPacks[selectedTab],
|
packs = emojiPacks,
|
||||||
onEmojiSelected = { entry ->
|
onEmojiSelected = { entry ->
|
||||||
text = text + ":${entry.shortcode}:"
|
text = text + ":${entry.shortcode}:"
|
||||||
showEmojiPackPicker = false
|
showEmojiPackPicker = false
|
||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxWidth().weight(1f),
|
modifier = Modifier.fillMaxWidth().weight(1f),
|
||||||
)
|
)
|
||||||
} else {
|
selectedTab == gifTabIndex -> GifSearchTab(
|
||||||
val currentText by rememberUpdatedState(text)
|
apiKey = gifApiKey,
|
||||||
AndroidView(
|
onGifSelected = { gif ->
|
||||||
factory = { ctx -> EmojiPickerView(ctx) },
|
showEmojiPackPicker = false
|
||||||
update = { view ->
|
onSendGif(gif.fullUrl)
|
||||||
view.setOnEmojiPickedListener { emojiViewItem: EmojiViewItem ->
|
|
||||||
text = currentText + emojiViewItem.emoji
|
|
||||||
showEmojiPackPicker = false
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxWidth().weight(1f),
|
modifier = Modifier.fillMaxWidth().weight(1f),
|
||||||
)
|
)
|
||||||
|
else -> {
|
||||||
|
val currentText by rememberUpdatedState(text)
|
||||||
|
AndroidView(
|
||||||
|
factory = { ctx -> EmojiPickerView(ctx) },
|
||||||
|
update = { view ->
|
||||||
|
view.setOnEmojiPickedListener { emojiViewItem: EmojiViewItem ->
|
||||||
|
text = currentText + emojiViewItem.emoji
|
||||||
|
showEmojiPackPicker = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth().weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1194,6 +1376,79 @@ private fun MessageInput(
|
|||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
val surfaceVariant = MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
val onSurface = MaterialTheme.colorScheme.onSurface
|
||||||
|
val onSurfaceVariant = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
val textStyle = MaterialTheme.typography.bodyLarge
|
||||||
|
val density = LocalDensity.current
|
||||||
|
AndroidView(
|
||||||
|
factory = { ctx ->
|
||||||
|
object : android.widget.EditText(ctx) {
|
||||||
|
override fun requestRectangleOnScreen(rect: android.graphics.Rect?, immediate: Boolean): Boolean {
|
||||||
|
// Disable system scroll-into-view; Compose imePadding() handles it
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}.apply {
|
||||||
|
hint = "Message #$channelName"
|
||||||
|
setHintTextColor(onSurfaceVariant.toArgb())
|
||||||
|
setTextColor(onSurface.toArgb())
|
||||||
|
setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, textStyle.fontSize.value)
|
||||||
|
background = android.graphics.drawable.GradientDrawable().apply {
|
||||||
|
setColor(surfaceVariant.toArgb())
|
||||||
|
cornerRadius = with(density) { 8.dp.toPx() }
|
||||||
|
}
|
||||||
|
setPadding(
|
||||||
|
with(density) { 16.dp.toPx().toInt() },
|
||||||
|
with(density) { 12.dp.toPx().toInt() },
|
||||||
|
with(density) { 16.dp.toPx().toInt() },
|
||||||
|
with(density) { 12.dp.toPx().toInt() },
|
||||||
|
)
|
||||||
|
maxLines = 8
|
||||||
|
inputType = android.text.InputType.TYPE_CLASS_TEXT or
|
||||||
|
android.text.InputType.TYPE_TEXT_FLAG_MULTI_LINE or
|
||||||
|
android.text.InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
|
||||||
|
isSingleLine = false
|
||||||
|
|
||||||
|
// Prevent EditText from fighting with Compose's imePadding()
|
||||||
|
imeOptions = android.view.inputmethod.EditorInfo.IME_FLAG_NO_EXTRACT_UI
|
||||||
|
|
||||||
|
addTextChangedListener(object : android.text.TextWatcher {
|
||||||
|
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
||||||
|
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
||||||
|
override fun afterTextChanged(s: android.text.Editable?) {
|
||||||
|
text = s?.toString() ?: ""
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
androidx.core.view.ViewCompat.setOnReceiveContentListener(
|
||||||
|
this,
|
||||||
|
arrayOf("image/*"),
|
||||||
|
) { _, payload ->
|
||||||
|
val clip = payload.clip
|
||||||
|
var remaining = payload
|
||||||
|
for (i in 0 until clip.itemCount) {
|
||||||
|
val uri = clip.getItemAt(i).uri
|
||||||
|
if (uri != null) {
|
||||||
|
attachedUris = attachedUris + uri
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Return null to indicate all content was consumed
|
||||||
|
if (clip.itemCount > 0 && (0 until clip.itemCount).any { clip.getItemAt(it).uri != null }) null
|
||||||
|
else remaining
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
update = { editText ->
|
||||||
|
if (editText.text.toString() != text) {
|
||||||
|
editText.setText(text)
|
||||||
|
editText.setSelection(text.length)
|
||||||
|
}
|
||||||
|
editText.hint = "Message #$channelName"
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.heightIn(max = 160.dp),
|
||||||
|
)
|
||||||
if (emojiPacks.isNotEmpty()) {
|
if (emojiPacks.isNotEmpty()) {
|
||||||
IconButton(onClick = { showEmojiPackPicker = true }) {
|
IconButton(onClick = { showEmojiPackPicker = true }) {
|
||||||
Icon(
|
Icon(
|
||||||
@@ -1202,19 +1457,6 @@ private fun MessageInput(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
TextField(
|
|
||||||
value = text,
|
|
||||||
onValueChange = { text = it },
|
|
||||||
placeholder = { Text("Message #$channelName", color = MaterialTheme.colorScheme.onSurfaceVariant) },
|
|
||||||
modifier = Modifier.weight(1f).clip(RoundedCornerShape(8.dp)).heightIn(max = 160.dp),
|
|
||||||
maxLines = 8,
|
|
||||||
colors = TextFieldDefaults.colors(
|
|
||||||
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
|
||||||
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
|
||||||
unfocusedIndicatorColor = Color.Transparent,
|
|
||||||
focusedIndicatorColor = Color.Transparent,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
val trimmed = text.trim()
|
val trimmed = text.trim()
|
||||||
@@ -1300,44 +1542,28 @@ private fun MessageContextMenu(
|
|||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (emojiPacks.isNotEmpty()) {
|
var selectedTab by remember { mutableStateOf(0) }
|
||||||
var selectedTab by remember { mutableStateOf(0) }
|
val tabs = if (emojiPacks.isNotEmpty()) listOf("Custom Emojis", "Unicode") else listOf("Unicode")
|
||||||
val tabs = listOf("Unicode") + emojiPacks.map { it.displayName }
|
val customTabIndex = if (emojiPacks.isNotEmpty()) 0 else -1
|
||||||
ScrollableTabRow(selectedTabIndex = selectedTab) {
|
ScrollableTabRow(selectedTabIndex = selectedTab) {
|
||||||
tabs.forEachIndexed { index, title ->
|
tabs.forEachIndexed { index, title ->
|
||||||
Tab(
|
Tab(
|
||||||
selected = selectedTab == index,
|
selected = selectedTab == index,
|
||||||
onClick = { selectedTab = index },
|
onClick = { selectedTab = index },
|
||||||
text = { Text(title, maxLines = 1) },
|
text = { Text(title, maxLines = 1) },
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
when {
|
|
||||||
selectedTab == 0 -> AndroidView(
|
|
||||||
factory = { ctx -> EmojiPickerView(ctx) },
|
|
||||||
update = { view ->
|
|
||||||
view.setOnEmojiPickedListener { emojiViewItem: EmojiViewItem ->
|
|
||||||
currentOnReact(emojiViewItem.emoji)
|
|
||||||
showEmojiPicker = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth().weight(1f),
|
|
||||||
)
|
)
|
||||||
else -> {
|
|
||||||
val pack = emojiPacks[selectedTab - 1]
|
|
||||||
CustomEmojiGrid(
|
|
||||||
pack = pack,
|
|
||||||
onEmojiSelected = { entry ->
|
|
||||||
currentOnReact(entry.mxcUrl)
|
|
||||||
showEmojiPicker = false
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth().weight(1f),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
when {
|
||||||
AndroidView(
|
selectedTab == customTabIndex -> CollapsableCustomEmojiPacks(
|
||||||
|
packs = emojiPacks,
|
||||||
|
onEmojiSelected = { entry ->
|
||||||
|
currentOnReact(entry.mxcUrl)
|
||||||
|
showEmojiPicker = false
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth().weight(1f),
|
||||||
|
)
|
||||||
|
else -> AndroidView(
|
||||||
factory = { ctx -> EmojiPickerView(ctx) },
|
factory = { ctx -> EmojiPickerView(ctx) },
|
||||||
update = { view ->
|
update = { view ->
|
||||||
view.setOnEmojiPickedListener { emojiViewItem: EmojiViewItem ->
|
view.setOnEmojiPickedListener { emojiViewItem: EmojiViewItem ->
|
||||||
@@ -1345,9 +1571,7 @@ private fun MessageContextMenu(
|
|||||||
showEmojiPicker = false
|
showEmojiPicker = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth().weight(1f),
|
||||||
.fillMaxWidth()
|
|
||||||
.weight(1f),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1510,6 +1734,158 @@ private fun EditModeBar(body: String, onDismiss: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun GifSearchTab(
|
||||||
|
apiKey: String,
|
||||||
|
onGifSelected: (GiphyResult) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val gifRepo = remember { GifRepository() }
|
||||||
|
var query by remember { mutableStateOf("") }
|
||||||
|
val results = remember { mutableStateListOf<GiphyResult>() }
|
||||||
|
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||||
|
var isLoading by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LaunchedEffect(query, apiKey) {
|
||||||
|
if (query.isNotBlank()) kotlinx.coroutines.delay(400)
|
||||||
|
if (apiKey.isBlank()) {
|
||||||
|
errorMessage = "No GIPHY API key — add one in Settings → Customization."
|
||||||
|
isLoading = false
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = null
|
||||||
|
try {
|
||||||
|
val list = withContext(Dispatchers.IO) {
|
||||||
|
if (query.isBlank()) gifRepo.trending(apiKey) else gifRepo.search(apiKey, query)
|
||||||
|
}
|
||||||
|
results.clear()
|
||||||
|
results.addAll(list)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
errorMessage = e.message ?: "Unknown error"
|
||||||
|
} finally {
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val keyboardController = LocalSoftwareKeyboardController.current
|
||||||
|
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
TextField(
|
||||||
|
value = query,
|
||||||
|
onValueChange = { query = it },
|
||||||
|
placeholder = { Text("Search GIFs…") },
|
||||||
|
leadingIcon = { Icon(Icons.Default.Search, null) },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||||
|
.clip(RoundedCornerShape(24.dp)),
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||||
|
keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
|
||||||
|
colors = TextFieldDefaults.colors(
|
||||||
|
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
unfocusedIndicatorColor = Color.Transparent,
|
||||||
|
focusedIndicatorColor = Color.Transparent,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
when {
|
||||||
|
isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
androidx.compose.material3.CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
errorMessage != null -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Text("Error: $errorMessage", color = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
results.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Text("No GIFs found", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
else -> LazyVerticalGrid(
|
||||||
|
columns = GridCells.Fixed(2),
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(4.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
items(results, key = { it.id }) { gif ->
|
||||||
|
val ratio = if (gif.previewHeight > 0) gif.previewWidth.toFloat() / gif.previewHeight else 1f
|
||||||
|
AsyncImage(
|
||||||
|
model = gif.previewUrl,
|
||||||
|
contentDescription = gif.title,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.aspectRatio(ratio)
|
||||||
|
.clip(RoundedCornerShape(4.dp))
|
||||||
|
.clickable { onGifSelected(gif) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CollapsableCustomEmojiPacks(
|
||||||
|
packs: List<EmojiPack>,
|
||||||
|
onEmojiSelected: (com.example.fluffytrix.data.model.EmojiEntry) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val expanded = remember { mutableStateMapOf<String, Boolean>().also { map -> packs.forEach { map[it.packId] = true } } }
|
||||||
|
LazyVerticalGrid(
|
||||||
|
columns = GridCells.Adaptive(56.dp),
|
||||||
|
modifier = modifier,
|
||||||
|
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
) {
|
||||||
|
packs.forEach { pack ->
|
||||||
|
val isExpanded = expanded[pack.packId] != false
|
||||||
|
item(key = "header_${pack.packId}", span = { GridItemSpan(maxLineSpan) }) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { expanded[pack.packId] = !isExpanded }
|
||||||
|
.padding(horizontal = 4.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = pack.displayName,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Icon(
|
||||||
|
imageVector = if (isExpanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown,
|
||||||
|
contentDescription = if (isExpanded) "Collapse" else "Expand",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isExpanded) {
|
||||||
|
items(pack.emojis, key = { "${pack.packId}_${it.shortcode}" }) { entry ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable { onEmojiSelected(entry) }
|
||||||
|
.padding(4.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model = entry.resolvedUrl,
|
||||||
|
contentDescription = entry.shortcode,
|
||||||
|
modifier = Modifier.size(40.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = entry.shortcode,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun CustomEmojiGrid(
|
private fun CustomEmojiGrid(
|
||||||
pack: EmojiPack,
|
pack: EmojiPack,
|
||||||
@@ -1543,3 +1919,50 @@ private fun CustomEmojiGrid(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun UserProfileSheet(
|
||||||
|
userId: String,
|
||||||
|
displayName: String,
|
||||||
|
avatarUrl: String?,
|
||||||
|
currentUserId: String?,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onStartDm: () -> Unit,
|
||||||
|
) {
|
||||||
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
|
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 24.dp)
|
||||||
|
.padding(bottom = 32.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
if (avatarUrl != null) {
|
||||||
|
AsyncImage(
|
||||||
|
model = avatarUrl,
|
||||||
|
contentDescription = displayName,
|
||||||
|
modifier = Modifier.size(80.dp).clip(CircleShape),
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val color = remember(displayName) { colorForSender(displayName) }
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.size(80.dp).clip(CircleShape).background(color.copy(alpha = 0.3f)),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(displayName.take(1).uppercase(), fontSize = 32.sp, fontWeight = FontWeight.Bold, color = color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(displayName, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||||
|
Text(userId, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
if (userId != currentUserId) {
|
||||||
|
Button(onClick = onStartDm, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text("Message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
Text(
|
androidx.compose.foundation.text.selection.SelectionContainer {
|
||||||
text = value,
|
Text(
|
||||||
style = MaterialTheme.typography.bodySmall,
|
text = value,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
10
app/src/main/res/drawable/ic_notification.xml
Normal file
10
app/src/main/res/drawable/ic_notification.xml
Normal 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>
|
||||||
4
app/src/main/res/xml/file_provider_paths.xml
Normal file
4
app/src/main/res/xml/file_provider_paths.xml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths>
|
||||||
|
<cache-path name="cache" path="." />
|
||||||
|
</paths>
|
||||||
@@ -21,6 +21,7 @@ media3 = "1.6.0"
|
|||||||
markdownRenderer = "0.37.0"
|
markdownRenderer = "0.37.0"
|
||||||
emojiPicker = "1.6.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" }
|
||||||
@@ -82,6 +83,9 @@ markdown-renderer-coil3 = { group = "com.mikepenz", name = "multiplatform-markdo
|
|||||||
# Jetpack Emoji Picker
|
# Jetpack Emoji Picker
|
||||||
emoji-picker = { group = "androidx.emoji2", name = "emoji2-emojipicker", version.ref = "emojiPicker" }
|
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" }
|
||||||
|
|||||||
Reference in New Issue
Block a user