Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7eb4dd8e81 | ||
|
|
e3fec77359 | ||
|
|
7009dbf692 | ||
|
|
9a2bd3816f | ||
|
|
44c743d039 | ||
|
|
a124dc4cc4 | ||
|
|
4a8134dec0 | ||
|
|
07085449a4 | ||
|
|
9defad522c | ||
|
|
47196dbea4 | ||
|
|
30191170f1 | ||
|
|
1485a767ed | ||
|
|
0e789b5d91 | ||
|
|
410615bd89 | ||
|
|
a5951e03cf | ||
|
|
175c06a1a8 | ||
|
|
c7a646ae73 | ||
|
|
7a44e836a9 | ||
|
|
4dae4045ae | ||
|
|
9df4789b62 | ||
|
|
ea7e3fd3ed | ||
|
|
a76c8d117b | ||
|
|
9ed29c90ba | ||
|
|
80c6426d27 | ||
|
|
801347ccb1 | ||
|
|
19daea119d | ||
|
|
8b2f0faa59 | ||
|
|
a2d28ecc40 | ||
|
|
cd5a393a01 | ||
|
|
3bfe2308ba | ||
|
|
eed9d401da | ||
|
|
3943e2baab | ||
|
|
d5ea5570b6 | ||
|
|
26f9e577f4 | ||
|
|
dd416711e2 | ||
|
|
b90279e174 | ||
|
|
25ba03562a | ||
|
|
010a8b4060 | ||
|
|
750d46bc9e | ||
|
|
790d24c24b | ||
|
|
4fbc8446d3 | ||
|
|
b4518ce360 | ||
|
|
0f4bb71a39 | ||
|
|
37003e2fc4 | ||
|
|
154906017e | ||
|
|
91e4e3b43d | ||
|
|
ea90a43909 | ||
|
|
938b192136 | ||
|
|
1f966de654 | ||
|
|
00821a864b | ||
|
|
28550f8e25 | ||
|
|
a759ad48e4 | ||
|
|
0d6b92b0c7 | ||
|
|
6a69d3a5b2 | ||
|
|
50b7f7a3f3 | ||
|
|
a65aa70a55 | ||
|
|
74a37320fe | ||
|
|
b8b3b83952 | ||
|
|
5c2115e8b7 | ||
|
|
ec337b8756 | ||
|
|
fcae7d5825 | ||
|
|
1e90b8d4b1 | ||
|
|
71a8240b1d | ||
|
|
ff383a7c6a | ||
|
|
15e8c984e2 | ||
|
|
0da1371345 | ||
|
|
b7c5b64022 | ||
|
|
a3ab489d44 | ||
|
|
b88674d6e2 | ||
|
|
e9eb071208 | ||
|
|
a2a4433a9d | ||
|
|
1c08525dfc | ||
|
|
83af44002c | ||
|
|
44acbfa6b7 |
@@ -17,3 +17,4 @@ captures
|
||||
!*.xcworkspace/contents.xcworkspacedata
|
||||
**/xcshareddata/WorkspaceSettings.xcsettings
|
||||
node_modules/
|
||||
.artifacts/
|
||||
@@ -0,0 +1,93 @@
|
||||
### [2026-06-20] v0.2.0 (Stable)
|
||||
|
||||
**What's Changed:**
|
||||
|
||||
* Fixed the issue that cause app crashing
|
||||
* Added screener for new message requests
|
||||
* Added request messages screen
|
||||
* Added contact list screen
|
||||
|
||||
---
|
||||
|
||||
### [2026-06-12] v0.1.9 (Pre-Release)
|
||||
|
||||
**What's Changed:**
|
||||
|
||||
* Fixed the issue that cause app crashing
|
||||
* Added relay management
|
||||
|
||||
---
|
||||
|
||||
### [2026-06-09] v0.1.8 (Pre-Release)
|
||||
|
||||
**What's Changed:**
|
||||
|
||||
* Added support for NIP-55 (use external signer like Amber)
|
||||
|
||||
---
|
||||
|
||||
### [2026-06-07] v0.1.7 (Pre-Release)
|
||||
|
||||
**What's Changed:**
|
||||
|
||||
* Added a screen for update profile
|
||||
* Fixed occasional splash screen hang
|
||||
* Improved the battery usage in background
|
||||
|
||||
---
|
||||
|
||||
### [2026-06-04] v0.1.6 (Pre-Release)
|
||||
|
||||
**What's Changed:**
|
||||
|
||||
* Added self chat
|
||||
* Fixed empty chat rooms on refreshes
|
||||
* Fixed crash on startup
|
||||
|
||||
---
|
||||
|
||||
### [2026-06-03] v0.1.5 (Pre-Release)
|
||||
|
||||
**What's Changed:**
|
||||
|
||||
* Added crash report screen
|
||||
* Added notification permission banner
|
||||
* Fixed navigation issues after create or import identity
|
||||
|
||||
---
|
||||
|
||||
### [2026-06-01] v0.1.4 (Pre-Release)
|
||||
|
||||
**What's Changed:**
|
||||
|
||||
* Added the deep link to allow user open chat screen directly via notification
|
||||
* Fixed the issue cause app crash if room has no member
|
||||
* Improve stability and performance
|
||||
|
||||
---
|
||||
|
||||
### [2026-05-29] v0.1.3 (Pre-Release)
|
||||
|
||||
**What's Changed:**
|
||||
|
||||
* Added basic notification when the app in background
|
||||
* Redesign the splash screen
|
||||
* Redesign the onboarding screen
|
||||
* Improved UX for the new identity creation and import screen
|
||||
* Fixed some issues cause app crashes
|
||||
|
||||
---
|
||||
|
||||
### [2026-05-24] v0.1.2 (Pre-Release)
|
||||
|
||||
**What's Changed:**
|
||||
|
||||
* Fixed the issue that cause app crash when open new chat room via QR
|
||||
|
||||
---
|
||||
|
||||
### [2026-05-23] v0.1.1 (Pre-Release)
|
||||
|
||||
**What's Changed:**
|
||||
|
||||
* Fixed the issue that cause app crash on startup
|
||||
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 296 KiB |
|
After Width: | Height: | Size: 444 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 185 KiB |
@@ -19,15 +19,15 @@ kotlin {
|
||||
androidMain.dependencies {
|
||||
implementation(libs.compose.uiToolingPreview)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation("androidx.navigation:navigation-compose:2.8.8")
|
||||
implementation("androidx.datastore:datastore-preferences:1.2.1")
|
||||
implementation("androidx.datastore:datastore-preferences-core:1.2.1")
|
||||
implementation("org.jetbrains.compose.material3:material3:1.11.0-alpha07")
|
||||
implementation("io.coil-kt.coil3:coil-compose:3.4.0")
|
||||
implementation("io.coil-kt.coil3:coil-network-okhttp:3.4.0")
|
||||
implementation("su.reya:nostr-sdk-kmp:0.2.3")
|
||||
implementation(libs.androidx.lifecycle.process)
|
||||
implementation(libs.androidx.lifecycle.runtimeCompose)
|
||||
implementation(libs.jetbrains.navigation3.ui)
|
||||
implementation(libs.jetbrains.lifecycle.viewmodelNavigation3)
|
||||
implementation(libs.androidx.core.splashscreen)
|
||||
implementation("su.reya:nostr-sdk-kmp:0.3.2")
|
||||
implementation("io.coil-kt.coil3:coil-compose:3.5.0")
|
||||
implementation("io.coil-kt.coil3:coil-network-okhttp:3.5.0")
|
||||
implementation("io.github.kalinjul.easyqrscan:scanner:0.7.0")
|
||||
implementation("androidx.lifecycle:lifecycle-process:2.8.0")
|
||||
implementation("io.github.alexzhirkevich:qrose:1.1.2")
|
||||
}
|
||||
commonMain.dependencies {
|
||||
@@ -39,6 +39,8 @@ kotlin {
|
||||
implementation(libs.compose.uiToolingPreview)
|
||||
implementation(libs.androidx.lifecycle.viewmodelCompose)
|
||||
implementation(libs.androidx.lifecycle.runtimeCompose)
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
implementation(libs.androidx.datastore)
|
||||
implementation(projects.shared)
|
||||
}
|
||||
commonTest.dependencies {
|
||||
@@ -67,7 +69,7 @@ android {
|
||||
minSdk = libs.versions.android.minSdk.get().toInt()
|
||||
targetSdk = libs.versions.android.targetSdk.get().toInt()
|
||||
versionCode = 1
|
||||
versionName = "0.1.1"
|
||||
versionName = "0.2.6"
|
||||
}
|
||||
packaging {
|
||||
resources {
|
||||
|
||||
@@ -7,10 +7,20 @@
|
||||
android:required="false" />
|
||||
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="nostrsigner" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
@@ -18,19 +28,41 @@
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
||||
|
||||
<activity
|
||||
android:name=".CrashActivity"
|
||||
android:exported="false"
|
||||
android:process=":crash_handler"
|
||||
android:theme="@android:style/Theme.Material.Light.NoActionBar" />
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.App.Starting"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data
|
||||
android:host="chat"
|
||||
android:scheme="coop" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".NostrForegroundService"
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
android:foregroundServiceType="remoteMessaging" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -1,18 +1,3 @@
|
||||
<!--
|
||||
~ Copyright (C) 2026 The Android Open Source Project
|
||||
~
|
||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||
~ you may not use this file except in compliance with the License.
|
||||
~ You may obtain a copy of the License at
|
||||
~
|
||||
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||
~
|
||||
~ Unless required by applicable law or agreed to in writing, software
|
||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
~ See the License for the specific language governing permissions and
|
||||
~ limitations under the License.
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="750dp"
|
||||
android:height="750dp"
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M440,680L520,680L520,520L680,520L680,440L520,440L520,280L440,280L440,440L280,440L280,520L440,520L440,680ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M280,720L280,240L360,240L360,720L280,720ZM440,880L440,80L520,80L520,880L440,880ZM120,560L120,400L200,400L200,560L120,560ZM600,720L600,240L680,240L680,720L600,720ZM760,560L760,400L840,400L840,560L760,560Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M160,880Q127,880 103.5,856.5Q80,833 80,800L80,360Q80,327 103.5,303.5Q127,280 160,280L360,280L360,160Q360,127 383.5,103.5Q407,80 440,80L520,80Q553,80 576.5,103.5Q600,127 600,160L600,280L800,280Q833,280 856.5,303.5Q880,327 880,360L880,800Q880,833 856.5,856.5Q833,880 800,880L160,880ZM160,800L800,800Q800,800 800,800Q800,800 800,800L800,360Q800,360 800,360Q800,360 800,360L600,360L600,360Q600,393 576.5,416.5Q553,440 520,440L440,440Q407,440 383.5,416.5Q360,393 360,360L360,360L160,360Q160,360 160,360Q160,360 160,360L160,800Q160,800 160,800Q160,800 160,800ZM240,720L480,720L480,702Q480,685 470.5,670.5Q461,656 444,648Q424,639 403.5,634.5Q383,630 360,630Q337,630 316.5,634.5Q296,639 276,648Q259,656 249.5,670.5Q240,685 240,702L240,720ZM560,660L720,660L720,600L560,600L560,660ZM402.5,582.5Q420,565 420,540Q420,515 402.5,497.5Q385,480 360,480Q335,480 317.5,497.5Q300,515 300,540Q300,565 317.5,582.5Q335,600 360,600Q385,600 402.5,582.5ZM560,540L720,540L720,480L560,480L560,540ZM440,360L520,360L520,160L440,160L440,360ZM480,580Q480,580 480,580Q480,580 480,580L480,580Q480,580 480,580Q480,580 480,580L480,580L480,580Q480,580 480,580Q480,580 480,580L480,580Q480,580 480,580Q480,580 480,580L480,580L480,580Q480,580 480,580Q480,580 480,580L480,580Q480,580 480,580Q480,580 480,580Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M360,840L360,760L240,760L240,680L320,680L320,280L240,280L240,200L360,200L360,120L440,120L440,200L520,200L520,120L600,120L600,205L600,205Q652,219 686,261.5Q720,304 720,360Q720,389 710,415.5Q700,442 682,463Q717,484 738.5,520Q760,556 760,600Q760,666 713,713Q666,760 600,760L600,760L600,840L520,840L520,760L440,760L440,840L360,840ZM400,440L560,440Q593,440 616.5,416.5Q640,393 640,360Q640,327 616.5,303.5Q593,280 560,280L400,280L400,440ZM400,680L600,680Q633,680 656.5,656.5Q680,633 680,600Q680,567 656.5,543.5Q633,520 600,520L400,520L400,680Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:autoMirrored="true"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880Q397,880 324,848.5ZM480,800Q534,800 584,782.5Q634,765 676,732L228,284Q195,326 177.5,376Q160,426 160,480Q160,614 253,707Q346,800 480,800ZM732,676Q765,634 782.5,584Q800,534 800,480Q800,346 707,253Q614,160 480,160Q426,160 376,177.5Q326,195 284,228L732,676ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M336,680L480,536L624,680L680,624L536,480L680,336L624,280L480,424L336,280L280,336L424,480L280,624L336,680ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M80,880L80,160Q80,127 103.5,103.5Q127,80 160,80L800,80Q833,80 856.5,103.5Q880,127 880,160L880,640Q880,673 856.5,696.5Q833,720 800,720L240,720L80,880ZM206,640L800,640Q800,640 800,640Q800,640 800,640L800,160Q800,160 800,160Q800,160 800,160L160,160Q160,160 160,160Q160,160 160,160L160,685L206,640ZM160,640L160,640L160,160Q160,160 160,160Q160,160 160,160L160,160Q160,160 160,160Q160,160 160,160L160,640Q160,640 160,640Q160,640 160,640Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M382,720L154,492L211,435L382,606L749,239L806,296L382,720Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M424,664L706,382L650,326L424,552L310,438L254,494L424,664ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M360,720Q327,720 303.5,696.5Q280,673 280,640L280,160Q280,127 303.5,103.5Q327,80 360,80L720,80Q753,80 776.5,103.5Q800,127 800,160L800,640Q800,673 776.5,696.5Q753,720 720,720L360,720ZM360,640L720,640Q720,640 720,640Q720,640 720,640L720,160Q720,160 720,160Q720,160 720,160L360,160Q360,160 360,160Q360,160 360,160L360,640Q360,640 360,640Q360,640 360,640ZM200,880Q167,880 143.5,856.5Q120,833 120,800L120,240L200,240L200,800Q200,800 200,800Q200,800 200,800L640,800L640,880L200,880ZM360,640Q360,640 360,640Q360,640 360,640L360,160Q360,160 360,160Q360,160 360,160L360,160Q360,160 360,160Q360,160 360,160L360,640Q360,640 360,640Q360,640 360,640Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,473 799.5,465.5Q799,458 799,453Q794,482 772,501Q750,520 720,520L640,520Q607,520 583.5,496.5Q560,473 560,440L560,400L400,400L400,320Q400,287 423.5,263.5Q447,240 480,240L520,240L520,240Q520,217 532.5,199.5Q545,182 563,171Q543,166 522.5,163Q502,160 480,160Q346,160 253,253Q160,346 160,480Q160,480 160,480Q160,480 160,480L360,480Q426,480 473,527Q520,574 520,640L520,680L400,680L400,790Q420,795 439.5,797.5Q459,800 480,800Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M440,680L520,680L520,440L440,440L440,680ZM508.5,348.5Q520,337 520,320Q520,303 508.5,291.5Q497,280 480,280Q463,280 451.5,291.5Q440,303 440,320Q440,337 451.5,348.5Q463,360 480,360Q497,360 508.5,348.5ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:autoMirrored="true"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M480,840L480,760L760,760Q760,760 760,760Q760,760 760,760L760,200Q760,200 760,200Q760,200 760,200L480,200L480,120L760,120Q793,120 816.5,143.5Q840,167 840,200L840,760Q840,793 816.5,816.5Q793,840 760,840L480,840ZM400,680L345,622L447,520L120,520L120,440L447,440L345,338L400,280L600,480L400,680Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M480,471L480,471Q480,471 480,471Q480,471 480,471Q480,471 480,471Q480,471 480,471Q480,471 480,471Q480,471 480,471L480,471ZM480,880Q447,880 423.5,856.5Q400,833 400,800L560,800Q560,833 536.5,856.5Q513,880 480,880ZM160,760L160,680L240,680L240,400Q240,316 290.5,251Q341,186 422,167Q412,189 406.5,213Q401,237 399,262Q364,283 342,319Q320,355 320,400L320,680L640,680L640,558Q660,561 680,561Q700,561 720,558L720,680L800,680L800,760L160,760ZM640,480L628,420Q616,415 605.5,409.5Q595,404 584,396L526,414L486,346L532,306Q530,293 530,280Q530,267 532,254L486,214L526,146L584,164Q595,156 605.5,150.5Q616,145 628,140L640,80L720,80L732,140Q744,145 754.5,150.5Q765,156 776,164L834,146L874,214L828,254Q830,267 830,280Q830,293 828,306L874,346L834,414L776,396Q765,404 754.5,409.5Q744,415 732,420L720,480L640,480ZM736.5,336.5Q760,313 760,280Q760,247 736.5,223.5Q713,200 680,200Q647,200 623.5,223.5Q600,247 600,280Q600,313 623.5,336.5Q647,360 680,360Q713,360 736.5,336.5Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:autoMirrored="true"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M760,760L760,600Q760,550 725,515Q690,480 640,480L273,480L417,624L360,680L120,440L360,200L417,256L273,400L640,400Q723,400 781.5,458.5Q840,517 840,600L840,760L760,760Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M480,680Q497,680 508.5,668.5Q520,657 520,640Q520,623 508.5,611.5Q497,600 480,600Q463,600 451.5,611.5Q440,623 440,640Q440,657 451.5,668.5Q463,680 480,680ZM440,520L520,520L520,280L440,280L440,520ZM330,840L120,630L120,330L330,120L630,120L840,330L840,630L630,840L330,840ZM364,760L596,760L760,596L760,364L596,200L364,200L200,364L200,596L364,760ZM480,480L480,480L480,480L480,480L480,480L480,480L480,480L480,480Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M720,560L720,440L600,440L600,360L720,360L720,240L800,240L800,360L920,360L920,440L800,440L800,560L720,560ZM247,433Q200,386 200,320Q200,254 247,207Q294,160 360,160Q426,160 473,207Q520,254 520,320Q520,386 473,433Q426,480 360,480Q294,480 247,433ZM40,800L40,688Q40,654 57.5,625.5Q75,597 104,582Q166,551 230,535.5Q294,520 360,520Q426,520 490,535.5Q554,551 616,582Q645,597 662.5,625.5Q680,654 680,688L680,800L40,800ZM120,720L600,720L600,688Q600,677 594.5,668Q589,659 580,654Q526,627 471,613.5Q416,600 360,600Q304,600 249,613.5Q194,627 140,654Q131,659 125.5,668Q120,677 120,688L120,720ZM416.5,376.5Q440,353 440,320Q440,287 416.5,263.5Q393,240 360,240Q327,240 303.5,263.5Q280,287 280,320Q280,353 303.5,376.5Q327,400 360,400Q393,400 416.5,376.5ZM360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320ZM360,720L360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720L360,720Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M680,880Q630,880 595,845Q560,810 560,760Q560,754 563,732L282,568Q266,583 245,591.5Q224,600 200,600Q150,600 115,565Q80,530 80,480Q80,430 115,395Q150,360 200,360Q224,360 245,368.5Q266,377 282,392L563,228Q561,221 560.5,214.5Q560,208 560,200Q560,150 595,115Q630,80 680,80Q730,80 765,115Q800,150 800,200Q800,250 765,285Q730,320 680,320Q656,320 635,311.5Q614,303 598,288L317,452Q319,459 319.5,465.5Q320,472 320,480Q320,488 319.5,494.5Q319,501 317,508L598,672Q614,657 635,648.5Q656,640 680,640Q730,640 765,675Q800,710 800,760Q800,810 765,845Q730,880 680,880ZM680,800Q697,800 708.5,788.5Q720,777 720,760Q720,743 708.5,731.5Q697,720 680,720Q663,720 651.5,731.5Q640,743 640,760Q640,777 651.5,788.5Q663,800 680,800ZM200,520Q217,520 228.5,508.5Q240,497 240,480Q240,463 228.5,451.5Q217,440 200,440Q183,440 171.5,451.5Q160,463 160,480Q160,497 171.5,508.5Q183,520 200,520ZM708.5,228.5Q720,217 720,200Q720,183 708.5,171.5Q697,160 680,160Q663,160 651.5,171.5Q640,183 640,200Q640,217 651.5,228.5Q663,240 680,240Q697,240 708.5,228.5ZM680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760ZM200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480ZM680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M330,840L120,630L120,330L330,120L630,120L840,330L840,630L630,840L330,840ZM366,650L480,536L594,650L650,594L536,480L650,366L594,310L480,424L366,310L310,366L424,480L310,594L366,650ZM364,760L596,760L760,596L760,364L596,200L364,200L200,364L200,596L364,760ZM480,480L480,480L480,480L480,480L480,480L480,480L480,480L480,480Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,49 @@
|
||||
package su.reya.coop
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
class AndroidConnectivityMonitor(context: Context) : ConnectivityMonitor {
|
||||
private val manager =
|
||||
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
|
||||
private val _isMobileData = MutableStateFlow(checkIsMobileData())
|
||||
override val isMobileData: StateFlow<Boolean> = _isMobileData.asStateFlow()
|
||||
|
||||
init {
|
||||
val networkRequest = NetworkRequest.Builder()
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.build()
|
||||
|
||||
manager.registerNetworkCallback(
|
||||
networkRequest,
|
||||
object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
_isMobileData.value = checkIsMobileData()
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
_isMobileData.value = checkIsMobileData()
|
||||
}
|
||||
|
||||
override fun onCapabilitiesChanged(
|
||||
network: Network,
|
||||
networkCapabilities: NetworkCapabilities
|
||||
) {
|
||||
_isMobileData.value = checkIsMobileData()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun checkIsMobileData(): Boolean {
|
||||
val activeNetwork = manager.activeNetwork ?: return false
|
||||
val capabilities = manager.getNetworkCapabilities(activeNetwork) ?: return false
|
||||
return capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package su.reya.coop
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.net.toUri
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import rust.nostr.sdk.UnsignedEvent
|
||||
import su.reya.coop.nostr.ExternalSignerHandler
|
||||
import su.reya.coop.nostr.ExternalSignerResult
|
||||
|
||||
class AndroidExternalSigner(
|
||||
private val context: Context,
|
||||
private val launcher: ExternalSignerLauncher,
|
||||
) : ExternalSignerHandler {
|
||||
private var cachedPackageName: String? = null
|
||||
|
||||
private data class ContentResolverResult(
|
||||
val result: String,
|
||||
val event: String? = null,
|
||||
)
|
||||
|
||||
private fun queryContentResolver(
|
||||
type: String,
|
||||
payload: String,
|
||||
pubkey: PublicKey? = null,
|
||||
currentUser: PublicKey? = null,
|
||||
): ContentResolverResult? {
|
||||
val uri = "content://$cachedPackageName.${type.uppercase()}".toUri()
|
||||
val projection = mutableListOf<String?>().apply {
|
||||
add(payload)
|
||||
add(pubkey?.toHex() ?: "")
|
||||
add(currentUser?.toHex() ?: "")
|
||||
}
|
||||
|
||||
val cursor = context.contentResolver.query(
|
||||
uri,
|
||||
projection.toTypedArray(),
|
||||
null, null, null,
|
||||
) ?: return null
|
||||
|
||||
return cursor.use {
|
||||
if (it.getColumnIndex("rejected") > -1) return null
|
||||
if (it.moveToFirst()) {
|
||||
val resultIndex = it.getColumnIndex("result")
|
||||
val result = if (resultIndex > -1) it.getString(resultIndex) else null
|
||||
|
||||
val eventIndex = it.getColumnIndex("event")
|
||||
val event = if (eventIndex > -1) it.getString(eventIndex) else null
|
||||
|
||||
ContentResolverResult(result = result!!, event = event)
|
||||
} else null
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun request(
|
||||
type: String,
|
||||
payload: String,
|
||||
pubkey: PublicKey? = null,
|
||||
currentUser: PublicKey? = null,
|
||||
resultKey: String = "result",
|
||||
extras: Map<String, String> = emptyMap(),
|
||||
): String? {
|
||||
// Try Content Resolver first
|
||||
queryContentResolver(type, payload, pubkey, currentUser)?.let {
|
||||
return if (resultKey == "event") it.event else it.result
|
||||
}
|
||||
|
||||
// Fall back to Intent
|
||||
val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:$payload".toUri()).apply {
|
||||
`package` = cachedPackageName
|
||||
putExtra("type", type)
|
||||
if (pubkey != null) putExtra("pubkey", pubkey.toHex())
|
||||
if (currentUser != null) putExtra("current_user", currentUser.toHex())
|
||||
extras.forEach { (k, v) -> putExtra(k, v) }
|
||||
}
|
||||
|
||||
val result = launcher.launch(intent)
|
||||
if (result.resultCode != Activity.RESULT_OK) return null
|
||||
|
||||
val data = result.data ?: return null
|
||||
if (data.getBooleanExtra("rejected", false)) return null
|
||||
|
||||
return data.getStringExtra(resultKey)
|
||||
}
|
||||
|
||||
override fun isAvailable(): Boolean {
|
||||
val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:".toUri())
|
||||
return context.packageManager.queryIntentActivities(intent, 0).isNotEmpty()
|
||||
}
|
||||
|
||||
override fun setPackageName(packageName: String) {
|
||||
cachedPackageName = packageName
|
||||
}
|
||||
|
||||
override suspend fun getPublicKey(permissions: String?): ExternalSignerResult? {
|
||||
val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:".toUri()).apply {
|
||||
putExtra("type", "get_public_key")
|
||||
if (permissions != null) putExtra("permissions", permissions)
|
||||
}
|
||||
|
||||
val result = launcher.launch(intent)
|
||||
if (result.resultCode != Activity.RESULT_OK) return null
|
||||
|
||||
val data = result.data ?: return null
|
||||
if (data.getBooleanExtra("rejected", false)) return null
|
||||
|
||||
val pubkey = data.getStringExtra("result") ?: return null
|
||||
val packageName = data.getStringExtra("package") ?: return null
|
||||
cachedPackageName = packageName
|
||||
|
||||
return ExternalSignerResult(PublicKey.parse(pubkey), packageName)
|
||||
}
|
||||
|
||||
override suspend fun signEvent(event: UnsignedEvent, currentUser: PublicKey): String? {
|
||||
val extras = event.id()?.let { mapOf("id" to it.toHex()) } ?: emptyMap()
|
||||
return request(
|
||||
type = "sign_event",
|
||||
payload = event.asJson(),
|
||||
currentUser = currentUser,
|
||||
resultKey = "event",
|
||||
extras = extras,
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun nip04Encrypt(plaintext: String, pubkey: PublicKey): String? {
|
||||
return request("nip04_encrypt", plaintext, pubkey)
|
||||
}
|
||||
|
||||
override suspend fun nip04Decrypt(ciphertext: String, pubkey: PublicKey): String? {
|
||||
return request("nip04_decrypt", ciphertext, pubkey)
|
||||
}
|
||||
|
||||
override suspend fun nip44Encrypt(
|
||||
plaintext: String,
|
||||
pubkey: PublicKey,
|
||||
currentUser: PublicKey
|
||||
): String? {
|
||||
return request("nip44_encrypt", plaintext, pubkey, currentUser)
|
||||
}
|
||||
|
||||
override suspend fun nip44Decrypt(
|
||||
ciphertext: String,
|
||||
pubkey: PublicKey,
|
||||
currentUser: PublicKey
|
||||
): String? {
|
||||
return request("nip44_decrypt", ciphertext, pubkey, currentUser)
|
||||
}
|
||||
}
|
||||
@@ -1,254 +1,324 @@
|
||||
package su.reya.coop
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialExpressiveTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.MotionScheme
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.expressiveLightColorScheme
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.util.Consumer
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.toRoute
|
||||
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||
import androidx.navigation3.runtime.NavBackStack
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import androidx.navigation3.runtime.entryProvider
|
||||
import androidx.navigation3.runtime.rememberNavBackStack
|
||||
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
import kotlinx.coroutines.launch
|
||||
import su.reya.coop.coop.storage.SecretStore
|
||||
import su.reya.coop.screens.ChatScreen
|
||||
import su.reya.coop.repository.AccountRepository
|
||||
import su.reya.coop.repository.ChatRepository
|
||||
import su.reya.coop.repository.SettingsRepository
|
||||
import su.reya.coop.screens.ContactListScreen
|
||||
import su.reya.coop.screens.HomeScreen
|
||||
import su.reya.coop.screens.ImportScreen
|
||||
import su.reya.coop.screens.MyQrScreen
|
||||
import su.reya.coop.screens.NewChatScreen
|
||||
import su.reya.coop.screens.NewIdentityScreen
|
||||
import su.reya.coop.screens.OnboardingScreen
|
||||
import su.reya.coop.screens.ProfileScreen
|
||||
import su.reya.coop.screens.RelayScreen
|
||||
import su.reya.coop.screens.RequestListScreen
|
||||
import su.reya.coop.screens.ScanScreen
|
||||
import su.reya.coop.screens.SettingsScreen
|
||||
import su.reya.coop.screens.UpdateProfileScreen
|
||||
import su.reya.coop.screens.chat.ChatScreen
|
||||
import su.reya.coop.viewmodel.AccountViewModel
|
||||
import su.reya.coop.viewmodel.ChatScreenViewModel
|
||||
import su.reya.coop.viewmodel.ChatViewModel
|
||||
import su.reya.coop.viewmodel.ProfileCache
|
||||
import su.reya.coop.viewmodel.SettingsViewModel
|
||||
|
||||
val LocalNostrViewModel = staticCompositionLocalOf<NostrViewModel> {
|
||||
error("No NostrViewModel provided")
|
||||
val LocalProfileCache = staticCompositionLocalOf<ProfileCache> {
|
||||
error("No ProfileCache provided")
|
||||
}
|
||||
|
||||
val LocalSettings = staticCompositionLocalOf<Settings> {
|
||||
error("No Settings provided")
|
||||
}
|
||||
|
||||
val LocalConnectivity = staticCompositionLocalOf<Boolean> {
|
||||
false
|
||||
}
|
||||
|
||||
val LocalSnackbarHostState = staticCompositionLocalOf<SnackbarHostState> {
|
||||
error("No SnackbarHostState provided")
|
||||
}
|
||||
|
||||
val LocalNavController = staticCompositionLocalOf<NavController> {
|
||||
error("No NavController provided")
|
||||
val LocalNavigator = staticCompositionLocalOf<Navigator> {
|
||||
error("No Navigator provided")
|
||||
}
|
||||
|
||||
val LocalScanResult = staticCompositionLocalOf<QrScanResult> {
|
||||
error("No QrScanResult provided")
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun App() {
|
||||
fun App(
|
||||
profileCache: ProfileCache,
|
||||
accountRepository: AccountRepository,
|
||||
chatRepository: ChatRepository,
|
||||
settingsRepository: SettingsRepository,
|
||||
connectivityMonitor: ConnectivityMonitor,
|
||||
) {
|
||||
val viewModelFactory = remember {
|
||||
object : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
val result = when {
|
||||
modelClass.isAssignableFrom(ChatViewModel::class.java) -> ChatViewModel(
|
||||
chatRepository
|
||||
)
|
||||
|
||||
modelClass.isAssignableFrom(AccountViewModel::class.java) -> AccountViewModel(
|
||||
accountRepository
|
||||
)
|
||||
|
||||
modelClass.isAssignableFrom(SettingsViewModel::class.java) -> SettingsViewModel(
|
||||
settingsRepository
|
||||
)
|
||||
|
||||
else -> throw IllegalArgumentException("Unknown ViewModel class")
|
||||
}
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return result as T
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val accountViewModel: AccountViewModel = viewModel(factory = viewModelFactory)
|
||||
val chatViewModel: ChatViewModel = viewModel(factory = viewModelFactory)
|
||||
val settingsViewModel: SettingsViewModel = viewModel(factory = viewModelFactory)
|
||||
|
||||
val context = LocalContext.current
|
||||
val navController = rememberNavController()
|
||||
val scope = rememberCoroutineScope()
|
||||
val darkMode = isSystemInDarkTheme()
|
||||
val activity = context as? ComponentActivity
|
||||
val backStack = rememberNavBackStack(Screen.Home)
|
||||
val navigator = remember(backStack) { Navigator(backStack) }
|
||||
val qrScanResult = remember { QrScanResult() }
|
||||
|
||||
// Get the signer required state
|
||||
val accountState by accountViewModel.state.collectAsStateWithLifecycle()
|
||||
val signerRequired = accountState.signerRequired
|
||||
|
||||
// Get the settings
|
||||
val settings by settingsViewModel.settings.collectAsStateWithLifecycle()
|
||||
|
||||
// Get connectivity status
|
||||
val isMobileData by connectivityMonitor.isMobileData.collectAsStateWithLifecycle()
|
||||
|
||||
// Snackbar
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
// Initialize Nostr View Model and Secret Store
|
||||
val secretStore = remember { SecretStore(context) }
|
||||
val viewModel: NostrViewModel = viewModel { NostrViewModel(NostrManager.instance, secretStore) }
|
||||
// Check if dark theme enabled
|
||||
val darkMode = when (settings.theme) {
|
||||
Theme.Light -> false
|
||||
Theme.Dark -> true
|
||||
Theme.System -> isSystemInDarkTheme()
|
||||
}
|
||||
|
||||
// Enabled the dynamic color scheme
|
||||
val colorScheme = when {
|
||||
android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S -> {
|
||||
// Enable the dynamic color scheme for Android 12+
|
||||
settings.dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
if (darkMode) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
|
||||
// When dark mode is enabled, use the dark color scheme
|
||||
darkMode -> darkColorScheme()
|
||||
// Fallback to the light color scheme
|
||||
else -> expressiveLightColorScheme()
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.errorEvents.collect { message ->
|
||||
snackbarHostState.showSnackbar(message)
|
||||
launch {
|
||||
accountViewModel.errorEvents.collect { message ->
|
||||
snackbarHostState.showSnackbar(message)
|
||||
}
|
||||
}
|
||||
launch {
|
||||
chatViewModel.errorEvents.collect { message ->
|
||||
snackbarHostState.showSnackbar(message)
|
||||
}
|
||||
}
|
||||
launch {
|
||||
profileCache.errorEvents.collect { message ->
|
||||
snackbarHostState.showSnackbar(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(activity) {
|
||||
activity?.let {
|
||||
fun handleIntent(intent: Intent) {
|
||||
val screen = Screen.fromIntent(intent)
|
||||
// Prevent pushing the same screen
|
||||
if ((screen != null) && (backStack.lastOrNull() != screen)) {
|
||||
navigator.navigate(screen)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle the intent that started the Activity
|
||||
handleIntent(it.intent)
|
||||
|
||||
// Handle new intents while the Activity is running
|
||||
val listener = Consumer<Intent> { intent -> handleIntent(intent) }
|
||||
it.addOnNewIntentListener(listener)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(backStack.size) {
|
||||
if (backStack.isEmpty()) {
|
||||
(context as? Activity)?.finish()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(signerRequired) {
|
||||
if (signerRequired == true && backStack.last() != Screen.Onboarding) {
|
||||
backStack.clear()
|
||||
backStack.add(Screen.Onboarding)
|
||||
}
|
||||
}
|
||||
|
||||
MaterialExpressiveTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography(),
|
||||
motionScheme = MotionScheme.expressive(),
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalNostrViewModel provides viewModel,
|
||||
LocalProfileCache provides profileCache,
|
||||
LocalSettings provides settings,
|
||||
LocalConnectivity provides isMobileData,
|
||||
LocalSnackbarHostState provides snackbarHostState,
|
||||
LocalNavController provides navController,
|
||||
LocalNavigator provides navigator,
|
||||
LocalScanResult provides qrScanResult,
|
||||
) {
|
||||
val emptySecret by viewModel.emptySecret.collectAsState(initial = null)
|
||||
val isRelayListEmpty by viewModel.isRelayListEmpty.collectAsState()
|
||||
val sheetState = rememberModalBottomSheetState()
|
||||
|
||||
LaunchedEffect(emptySecret) {
|
||||
// Navigate to the home screen if the secret is already set
|
||||
if (emptySecret == false) {
|
||||
navController.navigate(Screen.Home) {
|
||||
popUpTo(Screen.Onboarding) { inclusive = true }
|
||||
NavDisplay(
|
||||
backStack = backStack,
|
||||
onBack = {
|
||||
if (backStack.size > 1) {
|
||||
backStack.removeLastOrNull()
|
||||
} else {
|
||||
(context as? Activity)?.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show loading screen while initializing
|
||||
if (emptySecret == null) return@CompositionLocalProvider
|
||||
|
||||
// Show the relay setup dialog if the msg relay list is empty
|
||||
if (isRelayListEmpty) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { viewModel.dismissRelayWarning() },
|
||||
sheetState = sheetState,
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(0.5f)
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = "Messaging Relays are required",
|
||||
style = MaterialTheme.typography.headlineSmallEmphasized.copy(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Text(
|
||||
text = "Coop cannot found your messaging relays. To send and receive messages on Coop, you need to set up at least one messaging relay.",
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Text(
|
||||
text = "Please click the button below to continue with the default set of relays. You can always change them later in the settings.",
|
||||
style = MaterialTheme.typography.bodyLarge.copy(
|
||||
fontStyle = FontStyle.Italic,
|
||||
),
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
viewModel.useDefaultMsgRelayList()
|
||||
sheetState.hide()
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(ButtonDefaults.MediumContainerHeight),
|
||||
) {
|
||||
Text(
|
||||
text = "Continue",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
)
|
||||
}
|
||||
},
|
||||
entryDecorators = listOf(
|
||||
rememberSaveableStateHolderNavEntryDecorator(),
|
||||
rememberViewModelStoreNavEntryDecorator()
|
||||
),
|
||||
entryProvider = entryProvider {
|
||||
entry<Screen.Home> {
|
||||
HomeScreen(accountViewModel, chatViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = if (emptySecret == false) Screen.Home else Screen.Onboarding
|
||||
) {
|
||||
composable<Screen.Onboarding> { backStackEntry ->
|
||||
OnboardingScreen(
|
||||
onOpenImport = { navController.navigate(Screen.Import) },
|
||||
onOpenNew = { navController.navigate(Screen.NewIdentity) }
|
||||
)
|
||||
}
|
||||
composable<Screen.Import> { backStackEntry ->
|
||||
val isCreating by viewModel.isCreating.collectAsState()
|
||||
|
||||
ImportScreen(
|
||||
isLoading = isCreating,
|
||||
onBack = { navController.popBackStack() },
|
||||
onSave = { secret ->
|
||||
viewModel.importIdentity(secret)
|
||||
}
|
||||
)
|
||||
}
|
||||
composable<Screen.NewIdentity> { backStackEntry ->
|
||||
val isCreating by viewModel.isCreating.collectAsState()
|
||||
|
||||
NewIdentityScreen(
|
||||
isLoading = isCreating,
|
||||
onBack = { navController.popBackStack() },
|
||||
onSave = { name, bio, uri ->
|
||||
val contentType = uri?.let { context.contentResolver.getType(it) }
|
||||
val picture = uri?.let {
|
||||
context.contentResolver.openInputStream(it)?.use { input ->
|
||||
input.readBytes()
|
||||
entry<Screen.RequestList> {
|
||||
RequestListScreen(chatViewModel)
|
||||
}
|
||||
entry<Screen.Onboarding> {
|
||||
OnboardingScreen(accountViewModel)
|
||||
}
|
||||
entry<Screen.Import> {
|
||||
ImportScreen(accountViewModel)
|
||||
}
|
||||
entry<Screen.NewIdentity> {
|
||||
NewIdentityScreen(accountViewModel)
|
||||
}
|
||||
entry<Screen.Chat> { key ->
|
||||
val factory = remember(key) {
|
||||
object : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return ChatScreenViewModel(
|
||||
key.id,
|
||||
key.screening,
|
||||
accountRepository,
|
||||
chatRepository
|
||||
) as T
|
||||
}
|
||||
}
|
||||
viewModel.createIdentity(name, bio, picture, contentType)
|
||||
}
|
||||
)
|
||||
ChatScreen(
|
||||
viewModel<ChatScreenViewModel>(
|
||||
key = key.id.toString(),
|
||||
factory = factory
|
||||
),
|
||||
accountViewModel
|
||||
)
|
||||
}
|
||||
entry<Screen.NewChat> {
|
||||
NewChatScreen(accountViewModel, chatViewModel)
|
||||
}
|
||||
entry<Screen.Profile> { key ->
|
||||
ProfileScreen(pubkey = key.pubkey, chatViewModel = chatViewModel)
|
||||
}
|
||||
entry<Screen.UpdateProfile> {
|
||||
UpdateProfileScreen(accountViewModel)
|
||||
}
|
||||
entry<Screen.Scan> {
|
||||
ScanScreen()
|
||||
}
|
||||
entry<Screen.MyQr> {
|
||||
MyQrScreen(accountViewModel)
|
||||
}
|
||||
entry<Screen.ContactList> {
|
||||
ContactListScreen(accountViewModel, chatViewModel)
|
||||
}
|
||||
entry<Screen.Relay> {
|
||||
RelayScreen(accountViewModel)
|
||||
}
|
||||
entry<Screen.Settings> {
|
||||
SettingsScreen(settingsViewModel)
|
||||
}
|
||||
}
|
||||
composable<Screen.Home> { backStackEntry ->
|
||||
HomeScreen(
|
||||
onOpenChat = { id -> navController.navigate(Screen.Chat(id)) },
|
||||
onNewChat = { navController.navigate(Screen.NewChat) }
|
||||
)
|
||||
}
|
||||
composable<Screen.Chat> { backStackEntry ->
|
||||
val chat: Screen.Chat = backStackEntry.toRoute()
|
||||
ChatScreen(
|
||||
id = chat.id,
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable<Screen.NewChat> { backStackEntry ->
|
||||
NewChatScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable<Screen.Scan> { backStackEntry ->
|
||||
ScanScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable<Screen.MyQr> { backStackEntry ->
|
||||
MyQrScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable<Screen.Relay> { backStackEntry ->
|
||||
RelayScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Navigator(private val backStack: NavBackStack<NavKey>) {
|
||||
fun navigate(route: NavKey) {
|
||||
backStack.add(route)
|
||||
}
|
||||
|
||||
fun goBack() {
|
||||
if (backStack.size > 1) {
|
||||
backStack.removeAt(backStack.lastIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class QrScanResult {
|
||||
var content by mutableStateOf<String?>(null)
|
||||
|
||||
fun clear() {
|
||||
content = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
package su.reya.coop.coop.storage
|
||||
package su.reya.coop
|
||||
|
||||
import android.content.Context
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
import android.util.Base64
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.first
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.security.KeyStore
|
||||
import javax.crypto.Cipher
|
||||
@@ -10,10 +15,9 @@ import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
|
||||
data class SecretEntry(
|
||||
val encrypted: String,
|
||||
val iv: String
|
||||
)
|
||||
private val Context.dataStore by preferencesDataStore("secret_store")
|
||||
|
||||
data class SecretEntry(val encrypted: String, val iv: String)
|
||||
|
||||
class SecretCrypto {
|
||||
private val keyAlias = "coop"
|
||||
@@ -21,11 +25,9 @@ class SecretCrypto {
|
||||
private val transformation = "AES/GCM/NoPadding"
|
||||
|
||||
fun encrypt(content: String): SecretEntry {
|
||||
// Initialize cipher
|
||||
val cipher = Cipher.getInstance(transformation)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey())
|
||||
|
||||
// Encrypt content
|
||||
val encrypted = cipher.doFinal(content.toByteArray())
|
||||
val iv = cipher.iv
|
||||
|
||||
@@ -39,12 +41,10 @@ class SecretCrypto {
|
||||
val encrypted = Base64.decode(entry.encrypted, Base64.NO_WRAP)
|
||||
val iv = Base64.decode(entry.iv, Base64.NO_WRAP)
|
||||
|
||||
// Initialize cipher
|
||||
val cipher = Cipher.getInstance(transformation)
|
||||
val spec = GCMParameterSpec(128, iv)
|
||||
cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), spec)
|
||||
|
||||
// Decrypt content
|
||||
val plaintext = cipher.doFinal(encrypted)
|
||||
|
||||
return String(plaintext, StandardCharsets.UTF_8)
|
||||
@@ -54,13 +54,9 @@ class SecretCrypto {
|
||||
val keyStore = KeyStore.getInstance(keyStoreType).apply { load(null) }
|
||||
val existingKey = keyStore.getKey(keyAlias, null)
|
||||
|
||||
// Return existing key if available
|
||||
if (existingKey is SecretKey) return existingKey
|
||||
|
||||
// Construct a new key generator
|
||||
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, keyStoreType)
|
||||
|
||||
// Initialize key generation parameters
|
||||
val spec = KeyGenParameterSpec.Builder(
|
||||
keyAlias,
|
||||
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
|
||||
@@ -70,9 +66,49 @@ class SecretCrypto {
|
||||
.setKeySize(256)
|
||||
.build()
|
||||
|
||||
// Generate a new key
|
||||
keyGenerator.init(spec)
|
||||
|
||||
return keyGenerator.generateKey()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AppStore(private val context: Context) : AppStorage {
|
||||
private val crypto = SecretCrypto()
|
||||
|
||||
override suspend fun get(key: String): String? {
|
||||
return context.dataStore.data.first()[stringPreferencesKey(key)]
|
||||
}
|
||||
|
||||
override suspend fun set(key: String, value: String) {
|
||||
context.dataStore.edit { it[stringPreferencesKey(key)] = value }
|
||||
}
|
||||
|
||||
override suspend fun getSecret(key: String): String? {
|
||||
val prefs = context.dataStore.data.first()
|
||||
val encrypted = prefs[stringPreferencesKey("${key}_encrypted")] ?: return null
|
||||
val iv = prefs[stringPreferencesKey("${key}_iv")] ?: return null
|
||||
|
||||
return crypto.decrypt(SecretEntry(encrypted, iv))
|
||||
}
|
||||
|
||||
override suspend fun setSecret(key: String, value: String) {
|
||||
val entry = crypto.encrypt(value)
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[stringPreferencesKey("${key}_encrypted")] = entry.encrypted
|
||||
prefs[stringPreferencesKey("${key}_iv")] = entry.iv
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun clear(key: String) {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs.remove(stringPreferencesKey(key))
|
||||
prefs.remove(stringPreferencesKey("${key}_encrypted"))
|
||||
prefs.remove(stringPreferencesKey("${key}_iv"))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun has(key: String): Boolean {
|
||||
val prefs = context.dataStore.data.first()
|
||||
return prefs.contains(stringPreferencesKey(key)) ||
|
||||
prefs.contains(stringPreferencesKey("${key}_encrypted"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package su.reya.coop
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.MaterialExpressiveTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
class CrashActivity : ComponentActivity() {
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val errorText = intent.getStringExtra("error") ?: "Unknown error"
|
||||
|
||||
setContent {
|
||||
MaterialExpressiveTheme {
|
||||
Scaffold(
|
||||
content = { innerPadding ->
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
"App Crashed",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
Text(
|
||||
"Please copy the log below and send it to the developer.",
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Text(
|
||||
text = errorText,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
FilledTonalButton(
|
||||
onClick = {
|
||||
finish();
|
||||
exitProcess(0)
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text("Exit")
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
val clipboard =
|
||||
getSystemService(CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val data = ClipData.newPlainText("Crash Log", errorText)
|
||||
clipboard.setPrimaryClip(data)
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text("Copy")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package su.reya.coop
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.activity.result.ActivityResult
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class ExternalSignerLauncher(
|
||||
private val mainDispatcher: CoroutineDispatcher = Dispatchers.Main,
|
||||
) {
|
||||
private var launcher: ActivityResultLauncher<Intent>? = null
|
||||
private var pendingResult: CompletableDeferred<ActivityResult>? = null
|
||||
private val mutex = Mutex()
|
||||
|
||||
fun register(launcher: ActivityResultLauncher<Intent>) {
|
||||
this.launcher = launcher
|
||||
}
|
||||
|
||||
suspend fun launch(intent: Intent): ActivityResult = mutex.withLock {
|
||||
withContext(mainDispatcher) {
|
||||
val deferred = CompletableDeferred<ActivityResult>()
|
||||
pendingResult = deferred
|
||||
launcher?.launch(intent) ?: throw IllegalStateException("Signer not registered")
|
||||
deferred.await()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun onResult(result: ActivityResult) {
|
||||
pendingResult?.complete(result)
|
||||
pendingResult = null
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,111 @@
|
||||
package su.reya.coop
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Process
|
||||
import android.util.Log
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import kotlinx.coroutines.MainScope
|
||||
import su.reya.coop.nostr.NostrManager
|
||||
import su.reya.coop.repository.AccountRepository
|
||||
import su.reya.coop.repository.ChatRepository
|
||||
import su.reya.coop.repository.MediaRepository
|
||||
import su.reya.coop.repository.SettingsRepository
|
||||
import su.reya.coop.viewmodel.ProfileCache
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
companion object {
|
||||
val externalSignerLauncher = ExternalSignerLauncher()
|
||||
}
|
||||
|
||||
private val profileCache by lazy { ProfileCache(NostrManager.instance) }
|
||||
private val scope = MainScope()
|
||||
|
||||
private val connectivityMonitor by lazy { AndroidConnectivityMonitor(this@MainActivity) }
|
||||
|
||||
private val settingsRepository by lazy {
|
||||
val storage = AppStore(this@MainActivity)
|
||||
SettingsRepository(storage, scope)
|
||||
}
|
||||
|
||||
private val accountRepository by lazy {
|
||||
val storage = AppStore(this@MainActivity)
|
||||
val mediaRepository = MediaRepository(settingsRepository)
|
||||
val androidSigner = AndroidExternalSigner(this@MainActivity, externalSignerLauncher)
|
||||
AccountRepository(
|
||||
NostrManager.instance,
|
||||
storage,
|
||||
mediaRepository,
|
||||
settingsRepository,
|
||||
scope,
|
||||
androidSigner
|
||||
)
|
||||
}
|
||||
|
||||
private val chatRepository by lazy {
|
||||
val mediaRepository = MediaRepository(settingsRepository)
|
||||
ChatRepository(NostrManager.instance, mediaRepository, settingsRepository, scope)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||
throwable.printStackTrace()
|
||||
|
||||
Log.e(
|
||||
"CoopCrash", "Uncaught exception in thread ${thread.name}", throwable
|
||||
)
|
||||
|
||||
// Start the Crash Activity
|
||||
val intent = Intent(this, CrashActivity::class.java).apply {
|
||||
putExtra("error", throwable.stackTraceToString())
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
|
||||
}
|
||||
startActivity(intent)
|
||||
|
||||
// Exit
|
||||
Process.killProcess(Process.myPid())
|
||||
exitProcess(1)
|
||||
}
|
||||
|
||||
val resultLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
externalSignerLauncher.onResult(result)
|
||||
}
|
||||
externalSignerLauncher.register(resultLauncher)
|
||||
|
||||
val splashScreen = installSplashScreen()
|
||||
enableEdgeToEdge()
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val intent = Intent(this, NostrForegroundService::class.java)
|
||||
val serviceIntent = Intent(this, NostrForegroundService::class.java)
|
||||
startForegroundService(serviceIntent)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
startForegroundService(intent)
|
||||
} else {
|
||||
startService(intent)
|
||||
// Keep the splash screen visible until the signer check is complete
|
||||
splashScreen.setKeepOnScreenCondition {
|
||||
accountRepository.state.value.signerRequired == null
|
||||
}
|
||||
|
||||
setContent {
|
||||
App()
|
||||
App(
|
||||
profileCache = profileCache,
|
||||
accountRepository = accountRepository,
|
||||
chatRepository = chatRepository,
|
||||
settingsRepository = settingsRepository,
|
||||
connectivityMonitor = connectivityMonitor,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,42 @@
|
||||
package su.reya.coop
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
sealed interface Screen {
|
||||
sealed interface Screen : NavKey {
|
||||
companion object {
|
||||
fun fromIntent(intent: Intent): Screen? {
|
||||
val data = intent.data ?: return null
|
||||
if (data.scheme != "coop") return null
|
||||
|
||||
return when (data.host) {
|
||||
// Matches coop://chat/{id}
|
||||
"chat" -> data.pathSegments.firstOrNull()?.toLongOrNull()?.let { Chat(it, false) }
|
||||
// Matches coop://profile/{pubkey}
|
||||
"profile" -> data.pathSegments.firstOrNull()?.let { Profile(it) }
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data object Home : Screen
|
||||
|
||||
@Serializable
|
||||
data class Chat(val id: Long) : Screen
|
||||
data object RequestList : Screen
|
||||
|
||||
@Serializable
|
||||
data class Chat(val id: Long, val screening: Boolean = false) : Screen
|
||||
|
||||
@Serializable
|
||||
data class Profile(val pubkey: String) : Screen
|
||||
|
||||
@Serializable
|
||||
data object ContactList : Screen
|
||||
|
||||
@Serializable
|
||||
data object UpdateProfile : Screen
|
||||
|
||||
@Serializable
|
||||
data object NewChat : Screen
|
||||
@@ -29,4 +58,7 @@ sealed interface Screen {
|
||||
|
||||
@Serializable
|
||||
data object Relay : Screen
|
||||
|
||||
@Serializable
|
||||
data object Settings : Screen
|
||||
}
|
||||
|
||||
@@ -3,85 +3,184 @@ package su.reya.coop
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import androidx.annotation.RequiresApi
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import su.reya.coop.nostr.NostrManager
|
||||
import java.io.File
|
||||
|
||||
private const val GROUP_KEY_MESSAGES = "su.reya.coop.MESSAGES"
|
||||
|
||||
class NostrForegroundService : Service() {
|
||||
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private val nostr = NostrManager.instance
|
||||
private val nostr by lazy { NostrManager.instance }
|
||||
private var notificationJob: Job? = null
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
private fun isUserInApp(): Boolean {
|
||||
return ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createNotificationChannel()
|
||||
startAsForeground()
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
createNotificationChannel()
|
||||
val notification = createNotification("Connecting to Nostr...")
|
||||
startForeground(1, notification)
|
||||
if (intent?.action == "STOP_SERVICE") {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
serviceScope.launch {
|
||||
// Start the nostr service in the foreground
|
||||
startAsForeground()
|
||||
|
||||
// Check if the service is already running
|
||||
if (notificationJob?.isActive == true) return START_STICKY
|
||||
|
||||
// Start the Nostr client
|
||||
notificationJob = serviceScope.launch {
|
||||
try {
|
||||
Log.d("Coop", "Starting Nostr in background")
|
||||
|
||||
// Create a database directory
|
||||
val dbDir = File(filesDir, "nostr")
|
||||
dbDir.mkdirs()
|
||||
|
||||
// Initialize Nostr client
|
||||
nostr.init(dbDir.absolutePath)
|
||||
try {
|
||||
nostr.init(dbDir.absolutePath)
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to initialize Nostr Client", e)
|
||||
}
|
||||
|
||||
// Connect to bootstrap relays
|
||||
nostr.connectBootstrapRelays()
|
||||
|
||||
// Handle notifications
|
||||
nostr.handleLiteNotifications { event ->
|
||||
if (!isUserInApp()) {
|
||||
showNewMessageNotification(event.content())
|
||||
nostr.handleNotifications(
|
||||
onMetadataUpdate = { pubkey, metadata ->
|
||||
serviceScope.launch { nostr.profiles.emitMetadataUpdate(pubkey, metadata) }
|
||||
},
|
||||
onContactListUpdate = { contacts ->
|
||||
serviceScope.launch { nostr.profiles.emitContactListUpdate(contacts) }
|
||||
},
|
||||
onNewMessage = { event ->
|
||||
serviceScope.launch {
|
||||
if (!isUserInApp()) {
|
||||
showNewMessageNotification(event.roomId(), event.content())
|
||||
}
|
||||
nostr.emitNewEvent(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
println("Failed to start Nostr in background: ${e.message}")
|
||||
Log.e("Coop", "Failed to start Nostr", e)
|
||||
}
|
||||
}
|
||||
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
private fun isUserInApp(): Boolean {
|
||||
return ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
|
||||
}
|
||||
|
||||
private fun startAsForeground() {
|
||||
val notification = createNotification()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
startForeground(1, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_REMOTE_MESSAGING)
|
||||
} else {
|
||||
startForeground(1, notification)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getStopServicePendingIntent(): PendingIntent {
|
||||
val intent = Intent(this, NostrForegroundService::class.java).apply {
|
||||
action = "STOP_SERVICE"
|
||||
}
|
||||
return PendingIntent.getService(this, 0, intent, PendingIntent.FLAG_IMMUTABLE)
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
val channel = NotificationChannel(
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
|
||||
val serviceChannel = NotificationChannel(
|
||||
"nostr_service",
|
||||
"Nostr Background Service",
|
||||
"Nostr Background Status",
|
||||
NotificationManager.IMPORTANCE_MIN
|
||||
).apply {
|
||||
setShowBadge(false)
|
||||
}
|
||||
manager?.createNotificationChannel(serviceChannel)
|
||||
|
||||
val messageChannel = NotificationChannel(
|
||||
"nostr_messages",
|
||||
"New Messages",
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
)
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
manager?.createNotificationChannel(channel)
|
||||
manager?.createNotificationChannel(messageChannel)
|
||||
}
|
||||
|
||||
private fun createNotification(content: String): Notification {
|
||||
return NotificationCompat.Builder(this, "nostr_service")
|
||||
.setContentTitle("Coop")
|
||||
.setContentText(content)
|
||||
.setSmallIcon(android.R.drawable.ic_menu_send)
|
||||
private fun createNotification(content: String? = null): Notification {
|
||||
val builder = NotificationCompat.Builder(this, "nostr_service")
|
||||
.setGroup(GROUP_KEY_MESSAGES)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||
.setCategory(Notification.CATEGORY_SERVICE)
|
||||
.addAction(R.drawable.ic_notification, "Stop", getStopServicePendingIntent())
|
||||
|
||||
if (content != null) {
|
||||
builder.setContentTitle("Coop")
|
||||
builder.setContentText(content)
|
||||
} else {
|
||||
builder.setContentTitle("Coop is active")
|
||||
}
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
private fun showNewMessageNotification(message: String) {
|
||||
val notification = NotificationCompat.Builder(this, "nostr_service")
|
||||
.setContentTitle("New Message")
|
||||
private fun showNewMessageNotification(roomId: Long, message: String) {
|
||||
val deepLinkUri = "coop://chat/$roomId".toUri()
|
||||
|
||||
val intent = Intent(
|
||||
Intent.ACTION_VIEW,
|
||||
deepLinkUri,
|
||||
this,
|
||||
MainActivity::class.java
|
||||
)
|
||||
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
roomId.toInt(),
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
val notification = NotificationCompat.Builder(this, "nostr_messages")
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle("You received a new message")
|
||||
.setContentText(message)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setCategory(Notification.CATEGORY_MESSAGE)
|
||||
.build()
|
||||
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
manager?.notify(System.currentTimeMillis().toInt(), notification)
|
||||
}
|
||||
@@ -90,4 +189,13 @@ class NostrForegroundService : Service() {
|
||||
super.onDestroy()
|
||||
serviceScope.cancel()
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
super.onTaskRemoved(rootIntent)
|
||||
if (!NotificationManagerCompat.from(this).areNotificationsEnabled()) {
|
||||
Log.d("Coop", "Stopping service on task removed because notifications are disabled")
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,349 +0,0 @@
|
||||
package su.reya.coop.screens
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.LoadingIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.ic_arrow_back
|
||||
import coop.composeapp.generated.resources.ic_send
|
||||
import kotlinx.coroutines.flow.first
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import rust.nostr.sdk.UnsignedEvent
|
||||
import su.reya.coop.LocalNostrViewModel
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
import su.reya.coop.formatAsGroupHeader
|
||||
import su.reya.coop.roomId
|
||||
import su.reya.coop.shared.Avatar
|
||||
import su.reya.coop.shared.displayNameFlow
|
||||
import su.reya.coop.shared.pictureFlow
|
||||
import su.reya.coop.short
|
||||
|
||||
@Composable
|
||||
fun ChatScreen(
|
||||
id: Long,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
|
||||
val room = viewModel.getChatRoom(id)
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
val displayName by remember(room) { room.displayNameFlow(viewModel) }.collectAsState("Loading...")
|
||||
val picture by remember(room) { room.pictureFlow(viewModel) }.collectAsState(null)
|
||||
|
||||
var text by remember { mutableStateOf("") }
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
|
||||
val messages = remember { mutableStateListOf<UnsignedEvent>() }
|
||||
val groupedMessages = remember(messages.toList()) {
|
||||
messages.groupBy { it.createdAt().formatAsGroupHeader() }
|
||||
}
|
||||
|
||||
fun setLoading(value: Boolean) {
|
||||
loading = value
|
||||
}
|
||||
|
||||
LaunchedEffect(id) {
|
||||
// Start loading spinner
|
||||
setLoading(true)
|
||||
|
||||
// Get messages
|
||||
val initialMessages = viewModel.getChatRoomMessages(id)
|
||||
messages.clear()
|
||||
messages.addAll(initialMessages)
|
||||
|
||||
// Get msg relays for each member
|
||||
val results = viewModel.chatRoomConnect(id)
|
||||
results.forEach { (member, relays) ->
|
||||
if (relays.isNotEmpty()) {
|
||||
val metadata = viewModel.getMetadata(member).first { it != null }
|
||||
val profile = metadata?.asRecord()
|
||||
val name = profile?.displayName ?: profile?.name ?: member.short()
|
||||
|
||||
snackbarHostState.showSnackbar("Connected to messaging relays for $name")
|
||||
}
|
||||
}
|
||||
|
||||
// Stop loading spinner
|
||||
setLoading(false)
|
||||
|
||||
// Handle new messages
|
||||
viewModel.newEvents.collect { event ->
|
||||
if (event.roomId() == id) {
|
||||
if (event.id() !in messages.map { it.id() }) {
|
||||
messages.add(0, event)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(messages.size) {
|
||||
if (messages.isNotEmpty()) {
|
||||
listState.animateScrollToItem(0)
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (loading) {
|
||||
LoadingIndicator(
|
||||
modifier = Modifier.size(32.dp),
|
||||
)
|
||||
} else {
|
||||
Avatar(
|
||||
picture = picture,
|
||||
description = displayName,
|
||||
size = 32.dp,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Text(
|
||||
text = displayName,
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
)
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||
contentDescription = "Back"
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
)
|
||||
)
|
||||
},
|
||||
content = { innerPadding ->
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = innerPadding.calculateTopPadding()),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(bottom = innerPadding.calculateBottomPadding())
|
||||
) {
|
||||
if (messages.isNotEmpty()) {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
reverseLayout = true,
|
||||
state = listState,
|
||||
) {
|
||||
groupedMessages.forEach { (dateHeader, messagesInGroup) ->
|
||||
items(
|
||||
messagesInGroup,
|
||||
key = { it.id()?.toBech32()!! }) { event ->
|
||||
ChatMessage(event)
|
||||
}
|
||||
item {
|
||||
DateSeparator(dateHeader)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = "No messages yet",
|
||||
style = MaterialTheme.typography.titleLargeEmphasized,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = "Your conversations will appear here.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
ChatInput(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
onSend = {
|
||||
viewModel.sendMessage(id, text)
|
||||
text = ""
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DateSeparator(date: String) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = date,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatMessage(
|
||||
rumor: UnsignedEvent
|
||||
) {
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
val currentUser = viewModel.currentUser()
|
||||
val isMine = rumor.author() == currentUser
|
||||
|
||||
val bubbleShape = if (isMine) {
|
||||
RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp, bottomStart = 20.dp, bottomEnd = 4.dp)
|
||||
} else {
|
||||
RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp, bottomStart = 4.dp, bottomEnd = 20.dp)
|
||||
}
|
||||
|
||||
val containerColor =
|
||||
if (isMine) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.tertiaryContainer
|
||||
|
||||
val contentColor =
|
||||
if (isMine) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onTertiaryContainer
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
contentAlignment = if (isMine) Alignment.CenterEnd else Alignment.CenterStart
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = if (isMine) Alignment.End else Alignment.Start
|
||||
) {
|
||||
Surface(
|
||||
color = containerColor,
|
||||
contentColor = contentColor,
|
||||
shape = bubbleShape,
|
||||
modifier = Modifier
|
||||
.widthIn(max = 280.dp)
|
||||
.clickable(
|
||||
onClick = {
|
||||
val id = rumor.id()
|
||||
if (id != null) {
|
||||
val sent = viewModel.isMessageSent(id)
|
||||
println("Sent: $sent")
|
||||
}
|
||||
}
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = rumor.content(),
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatInput(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
onSend: () -> Unit
|
||||
) {
|
||||
|
||||
Surface(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.height(IntrinsicSize.Min),
|
||||
verticalAlignment = Alignment.Bottom
|
||||
) {
|
||||
TextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
placeholder = { Text("Message") },
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent
|
||||
),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
FilledTonalIconButton(
|
||||
onClick = onSend,
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.aspectRatio(1f),
|
||||
colors = IconButtonDefaults.filledTonalIconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_send),
|
||||
contentDescription = "Send"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
package su.reya.coop.screens
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.PlainTooltip
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SegmentedListItem
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TooltipAnchorPosition
|
||||
import androidx.compose.material3.TooltipBox
|
||||
import androidx.compose.material3.TooltipDefaults
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberTooltipState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.ic_arrow_back
|
||||
import coop.composeapp.generated.resources.ic_check
|
||||
import coop.composeapp.generated.resources.ic_close
|
||||
import coop.composeapp.generated.resources.ic_plus
|
||||
import coop.composeapp.generated.resources.ic_scanner
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import rust.nostr.sdk.Nip05Address
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalProfileCache
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
import su.reya.coop.Screen
|
||||
import su.reya.coop.shared.Avatar
|
||||
import su.reya.coop.short
|
||||
import su.reya.coop.viewmodel.AccountViewModel
|
||||
import su.reya.coop.viewmodel.ChatViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun ContactListScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
chatViewModel: ChatViewModel
|
||||
) {
|
||||
val navigator = LocalNavigator.current
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val accountState by accountViewModel.state.collectAsStateWithLifecycle()
|
||||
val contactList = accountState.contactList
|
||||
var openAddContactDialog by remember { mutableStateOf(false) }
|
||||
var contactToDelete by remember { mutableStateOf<PublicKey?>(null) }
|
||||
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = "My Contacts",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized
|
||||
)
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navigator.goBack() }) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||
contentDescription = "Back"
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { navigator.navigate(Screen.Scan) }) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_scanner),
|
||||
contentDescription = "Scanner"
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
TooltipBox(
|
||||
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(
|
||||
TooltipAnchorPosition.Above,
|
||||
spacingBetweenTooltipAndAnchor = 8.dp,
|
||||
),
|
||||
tooltip = {
|
||||
PlainTooltip { Text("New Contact") }
|
||||
},
|
||||
state = rememberTooltipState(),
|
||||
) {
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = { openAddContactDialog = true },
|
||||
expanded = false,
|
||||
icon = {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_plus),
|
||||
contentDescription = "New Contact"
|
||||
)
|
||||
},
|
||||
text = { Text("New Contact") },
|
||||
)
|
||||
}
|
||||
},
|
||||
content = { innerPadding ->
|
||||
if (contactList.isNotEmpty()) {
|
||||
val contacts = remember(contactList) { contactList.toList() }
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = innerPadding.calculateTopPadding()),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap),
|
||||
) {
|
||||
itemsIndexed(contacts) { index, pubkey ->
|
||||
ContactListItem(
|
||||
pubkey = pubkey,
|
||||
index = index,
|
||||
total = contacts.size,
|
||||
onClick = {
|
||||
val room = chatViewModel.createChatRoom(listOf(pubkey))
|
||||
navigator.navigate(Screen.Chat(room))
|
||||
},
|
||||
onLongClick = {
|
||||
contactToDelete = pubkey
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "No contacts yet",
|
||||
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||
fontWeight = FontWeight.SemiBold
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = "Your contacts will appear here",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (openAddContactDialog) {
|
||||
AddContactDialog(
|
||||
accountViewModel = accountViewModel,
|
||||
onDismissRequest = { openAddContactDialog = false }
|
||||
)
|
||||
}
|
||||
|
||||
if (contactToDelete != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { contactToDelete = null },
|
||||
title = { Text("Delete Contact") },
|
||||
text = { Text("Are you sure you want to remove this contact?") },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
contactToDelete?.let { accountViewModel.removeContact(it) }
|
||||
contactToDelete = null
|
||||
}
|
||||
) {
|
||||
Text("Delete")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { contactToDelete = null }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun AddContactDialog(
|
||||
accountViewModel: AccountViewModel,
|
||||
onDismissRequest: () -> Unit,
|
||||
) {
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
var contact by remember { mutableStateOf("") }
|
||||
var isError by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = { onDismissRequest() },
|
||||
properties = DialogProperties(
|
||||
usePlatformDefaultWidth = false,
|
||||
dismissOnBackPress = true,
|
||||
decorFitsSystemWindows = false,
|
||||
),
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
title = {
|
||||
Text(
|
||||
text = "New Contact",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { onDismissRequest() }) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_close),
|
||||
contentDescription = "Close"
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = {
|
||||
accountViewModel.addContact(contact)
|
||||
onDismissRequest()
|
||||
}) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_check),
|
||||
contentDescription = "Add"
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
content = { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(innerPadding)
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = contact,
|
||||
onValueChange = { contact = it },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester),
|
||||
isError = isError,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
isError = contact.isNotEmpty() && !verifyContact(contact)
|
||||
}
|
||||
),
|
||||
singleLine = true,
|
||||
label = { Text(text = "Contact Address") },
|
||||
placeholder = { Text(text = "npub1... or user@example.com") },
|
||||
supportingText = {
|
||||
if (isError) {
|
||||
Text(text = "Contact address is invalid")
|
||||
} else {
|
||||
Text(text = "Only add contact you trust.")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun ContactListItem(
|
||||
pubkey: PublicKey,
|
||||
index: Int,
|
||||
total: Int = 0,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
) {
|
||||
val profileCache = LocalProfileCache.current
|
||||
val profileFlow = remember(pubkey) { profileCache.getMetadata(pubkey) }
|
||||
val profile by profileFlow.collectAsStateWithLifecycle(initialValue = null)
|
||||
|
||||
SegmentedListItem(
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
shapes = ListItemDefaults.segmentedShapes(
|
||||
index = index,
|
||||
count = total
|
||||
),
|
||||
leadingContent = {
|
||||
Avatar(
|
||||
picture = profile?.picture,
|
||||
description = profile?.name,
|
||||
size = 36.dp
|
||||
)
|
||||
},
|
||||
supportingContent = { Text(pubkey.short()) },
|
||||
content = {
|
||||
Text(
|
||||
text = profile?.name ?: "No name",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun verifyContact(address: String): Boolean {
|
||||
return try {
|
||||
if (address.contains("@")) Nip05Address.parse(address)
|
||||
else PublicKey.parse(address)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
println("Failed to parse contact: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,33 @@
|
||||
package su.reya.coop.screens
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ClipData
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Badge
|
||||
import androidx.compose.material3.BadgedBox
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
@@ -35,6 +49,7 @@ import androidx.compose.material3.SegmentedListItem
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TooltipAnchorPosition
|
||||
import androidx.compose.material3.TooltipBox
|
||||
import androidx.compose.material3.TooltipDefaults
|
||||
@@ -48,7 +63,6 @@ import androidx.compose.material3.rememberTooltipState
|
||||
import androidx.compose.material3.toShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -58,74 +72,112 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.ClipEntry
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.ic_close
|
||||
import coop.composeapp.generated.resources.ic_new_chat
|
||||
import coop.composeapp.generated.resources.ic_qr
|
||||
import coop.composeapp.generated.resources.ic_request
|
||||
import coop.composeapp.generated.resources.ic_scanner
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import su.reya.coop.LocalNavController
|
||||
import su.reya.coop.LocalNostrViewModel
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalProfileCache
|
||||
import su.reya.coop.LocalScanResult
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
import su.reya.coop.Room
|
||||
import su.reya.coop.RoomKind
|
||||
import su.reya.coop.RoomUiState
|
||||
import su.reya.coop.Screen
|
||||
import su.reya.coop.ago
|
||||
import su.reya.coop.shared.Avatar
|
||||
import su.reya.coop.shared.displayNameFlow
|
||||
import su.reya.coop.shared.pictureFlow
|
||||
import su.reya.coop.short
|
||||
import su.reya.coop.shared.getExpressiveFontFamily
|
||||
import su.reya.coop.uiStateFlow
|
||||
import su.reya.coop.viewmodel.AccountViewModel
|
||||
import su.reya.coop.viewmodel.ChatViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HomeScreen(
|
||||
onOpenChat: (Long) -> Unit,
|
||||
onNewChat: () -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
chatViewModel: ChatViewModel
|
||||
) {
|
||||
val clipboard = LocalClipboard.current
|
||||
val navController = LocalNavController.current
|
||||
val context = LocalContext.current
|
||||
val navigator = LocalNavigator.current
|
||||
val qrScanResult = LocalScanResult.current
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
|
||||
val currentUser = viewModel.currentUser() ?: return
|
||||
val currentUserProfile = viewModel.getMetadata(currentUser) ?: return
|
||||
|
||||
val userProfile by currentUserProfile.collectAsState(initial = null)
|
||||
val chatRooms by viewModel.chatRooms.collectAsState(initial = emptyList())
|
||||
val isPartialProcessedGiftWrap by viewModel.isPartialProcessedGiftWrap.collectAsState(initial = false)
|
||||
val clipboardManager = LocalClipboard.current
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
val sheetState = rememberModalBottomSheetState()
|
||||
val sheetState = rememberModalBottomSheetState(true)
|
||||
val listState = rememberLazyListState()
|
||||
val pullToRefreshState = rememberPullToRefreshState()
|
||||
|
||||
val userProfile by accountViewModel.currentUserProfile.collectAsStateWithLifecycle()
|
||||
val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle()
|
||||
|
||||
val accountState by accountViewModel.state.collectAsStateWithLifecycle()
|
||||
val isRelayListEmpty = accountState.isRelayListEmpty
|
||||
val isBannerDismissed = accountState.isNotificationBannerDismissed
|
||||
|
||||
val isSyncing by chatViewModel.isSyncing.collectAsStateWithLifecycle()
|
||||
val isPartialProcessedGiftWrap by chatViewModel.isPartialProcessedGiftWrap.collectAsStateWithLifecycle()
|
||||
|
||||
val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } }
|
||||
var showBottomSheet by remember { mutableStateOf(false) }
|
||||
var isRefreshing by remember { mutableStateOf(false) }
|
||||
var isBusy by remember { mutableStateOf(false) }
|
||||
|
||||
val savedStateHandle = navController.currentBackStackEntry?.savedStateHandle
|
||||
val qrResult by savedStateHandle
|
||||
?.getStateFlow<String?>("qr_result", null)
|
||||
?.collectAsState()
|
||||
?: remember { mutableStateOf(null) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.getChatRooms()
|
||||
var isNotificationEnabled by remember {
|
||||
mutableStateOf(NotificationManagerCompat.from(context).areNotificationsEnabled())
|
||||
}
|
||||
|
||||
LaunchedEffect(qrResult) {
|
||||
qrResult?.let { result ->
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { _ ->
|
||||
// State will be updated by LifecycleResumeEffect
|
||||
}
|
||||
|
||||
// Partition chat rooms into requests and ongoing
|
||||
val (requests, ongoing) = remember(chatRooms) {
|
||||
chatRooms.partition { it.kind == RoomKind.Request }
|
||||
}
|
||||
|
||||
LifecycleResumeEffect(context) {
|
||||
isNotificationEnabled = NotificationManagerCompat.from(context).areNotificationsEnabled()
|
||||
onPauseOrDispose { }
|
||||
}
|
||||
|
||||
LaunchedEffect(accountState.signerRequired) {
|
||||
chatViewModel.refreshChatRooms()
|
||||
}
|
||||
|
||||
LaunchedEffect(qrScanResult.content) {
|
||||
qrScanResult.content?.let { result ->
|
||||
runCatching { PublicKey.parse(result) }
|
||||
.onSuccess { pubkey ->
|
||||
val roomId = viewModel.createChatRoom(listOf(pubkey))
|
||||
navController.navigate(Screen.Chat(roomId))
|
||||
try {
|
||||
val roomId = chatViewModel.createChatRoom(listOf(pubkey))
|
||||
navigator.navigate(Screen.Chat(roomId))
|
||||
} catch (e: Exception) {
|
||||
e.message?.let { snackbarHostState.showSnackbar(it) }
|
||||
}
|
||||
}
|
||||
.onFailure { e -> println("Failed to parse QR: ${e.message}") }
|
||||
|
||||
// Clear the nav state
|
||||
navController.currentBackStackEntry?.savedStateHandle?.remove<String>("qr_result")
|
||||
qrScanResult.clear()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,14 +190,22 @@ fun HomeScreen(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
title = {
|
||||
Text(
|
||||
text = "Coop",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Coop",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized
|
||||
)
|
||||
if (isSyncing) {
|
||||
LoadingIndicator(modifier = Modifier.size(24.dp))
|
||||
}
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
// QR Scanner
|
||||
IconButton(onClick = { navController.navigate(Screen.Scan) }) {
|
||||
IconButton(onClick = { navigator.navigate(Screen.Scan) }) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_scanner),
|
||||
contentDescription = "Scanner"
|
||||
@@ -154,8 +214,8 @@ fun HomeScreen(
|
||||
// User
|
||||
IconButton(onClick = { showBottomSheet = true }) {
|
||||
Avatar(
|
||||
picture = userProfile?.asRecord()?.picture,
|
||||
description = userProfile?.asRecord()?.displayName,
|
||||
picture = userProfile?.picture,
|
||||
description = userProfile?.name ?: "No name",
|
||||
size = 32.dp,
|
||||
)
|
||||
}
|
||||
@@ -176,7 +236,7 @@ fun HomeScreen(
|
||||
state = rememberTooltipState(),
|
||||
) {
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = onNewChat,
|
||||
onClick = { navigator.navigate(Screen.NewChat) },
|
||||
expanded = expandedFab,
|
||||
icon = {
|
||||
Icon(
|
||||
@@ -189,201 +249,530 @@ fun HomeScreen(
|
||||
}
|
||||
},
|
||||
content = { innerPadding ->
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = innerPadding.calculateTopPadding()),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
||||
Column(
|
||||
modifier = Modifier.padding(top = innerPadding.calculateTopPadding()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
PullToRefreshBox(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
isRefreshing = isRefreshing,
|
||||
state = pullToRefreshState,
|
||||
onRefresh = {
|
||||
scope.launch {
|
||||
isRefreshing = true
|
||||
viewModel.refreshChatRooms()
|
||||
isRefreshing = false
|
||||
}
|
||||
},
|
||||
indicator = {
|
||||
PullToRefreshDefaults.LoadingIndicator(
|
||||
state = pullToRefreshState,
|
||||
isRefreshing = isRefreshing,
|
||||
modifier = Modifier.align(Alignment.TopCenter),
|
||||
)
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = !isNotificationEnabled && !isBannerDismissed,
|
||||
) {
|
||||
if (!isPartialProcessedGiftWrap) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
LoadingIndicator()
|
||||
}
|
||||
} else if (chatRooms.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = "No chats yet",
|
||||
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||
fontWeight = FontWeight.SemiBold
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
text = "Get message notifications",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
color = MaterialTheme.colorScheme.onSecondaryFixed,
|
||||
)
|
||||
Text(
|
||||
text = "Your conversations will appear here.",
|
||||
text = "Make sure you know when you have new messages.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
items(chatRooms.toList(), key = { it.id }) { room ->
|
||||
ChatRoom(
|
||||
room = room,
|
||||
onClick = { onOpenChat(room.id) }
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
TextButton(
|
||||
onClick = { accountViewModel.dismissNotificationBanner() },
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text(text = "Maybe later")
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
} else {
|
||||
// For older versions, navigate the user directly to App Notification Settings
|
||||
val intent =
|
||||
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
|
||||
putExtra(
|
||||
Settings.EXTRA_APP_PACKAGE,
|
||||
context.packageName
|
||||
)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text(text = "Turn on")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showBottomSheet) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { showBottomSheet = false },
|
||||
sheetState = sheetState,
|
||||
) {
|
||||
val pubkey = viewModel.currentUser()
|
||||
val shortPubkey = pubkey?.short() ?: "Not available"
|
||||
|
||||
val userName =
|
||||
userProfile?.asRecord()?.displayName
|
||||
?: userProfile?.asRecord()?.name
|
||||
?: "No name"
|
||||
|
||||
val dismissAndRun: (suspend () -> Unit) -> Unit = { action ->
|
||||
scope.launch {
|
||||
sheetState.hide()
|
||||
showBottomSheet = false
|
||||
action()
|
||||
}
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
||||
) {
|
||||
PullToRefreshBox(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
isRefreshing = isRefreshing,
|
||||
state = pullToRefreshState,
|
||||
onRefresh = {
|
||||
isRefreshing = true
|
||||
chatViewModel.refreshChatRooms()
|
||||
isRefreshing = false
|
||||
},
|
||||
indicator = {
|
||||
PullToRefreshDefaults.LoadingIndicator(
|
||||
state = pullToRefreshState,
|
||||
isRefreshing = isRefreshing,
|
||||
modifier = Modifier.align(Alignment.TopCenter),
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
if (!isPartialProcessedGiftWrap) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(84.dp)
|
||||
.clip(MaterialShapes.Cookie9Sided.toShape()),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Avatar(
|
||||
picture = userProfile?.asRecord()?.picture,
|
||||
description = userProfile?.asRecord()?.displayName,
|
||||
shape = MaterialShapes.Cookie9Sided.toShape(),
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Box(
|
||||
contentAlignment = Alignment.Center
|
||||
LoadingIndicator()
|
||||
}
|
||||
} else if (chatRooms.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = userName,
|
||||
style = MaterialTheme.typography.titleLargeEmphasized,
|
||||
text = "No chats yet",
|
||||
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||
fontWeight = FontWeight.SemiBold
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = "Your conversations will appear here.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
dismissAndRun { navController.navigate(Screen.MyQr) }
|
||||
},
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
if (requests.isNotEmpty()) {
|
||||
item { NewRequests(requests) }
|
||||
}
|
||||
|
||||
items(ongoing, key = { it.id }) { room ->
|
||||
Row(
|
||||
modifier = Modifier.animateItem()
|
||||
) {
|
||||
Text(text = shortPubkey)
|
||||
}
|
||||
FilledIconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
sheetState.hide()
|
||||
showBottomSheet = false
|
||||
navController.navigate(Screen.MyQr)
|
||||
}
|
||||
},
|
||||
shape = MaterialShapes.Square.toShape()
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_qr),
|
||||
contentDescription = "My QR"
|
||||
ChatRoom(
|
||||
room = room,
|
||||
onClick = { navigator.navigate(Screen.Chat(room.id)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.size(16.dp))
|
||||
BottomMenuList(onDismiss = dismissAndRun)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
if (showBottomSheet) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { showBottomSheet = false },
|
||||
sheetState = sheetState,
|
||||
) {
|
||||
val dismissAndRun: (suspend () -> Unit) -> Unit = { action ->
|
||||
scope.launch {
|
||||
sheetState.hide()
|
||||
showBottomSheet = false
|
||||
action()
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(84.dp)
|
||||
.clip(MaterialShapes.Cookie9Sided.toShape()),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Avatar(
|
||||
picture = userProfile?.picture,
|
||||
description = userProfile?.name ?: "No name",
|
||||
shape = MaterialShapes.Cookie9Sided.toShape(),
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Box(
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = userProfile?.name ?: "No name",
|
||||
style = MaterialTheme.typography.titleLargeEmphasized,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
userProfile?.publicKey?.let {
|
||||
val bech32 = it.toBech32()
|
||||
val data = ClipData.newPlainText(bech32, bech32)
|
||||
clipboardManager.setClipEntry(ClipEntry(data))
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text(text = userProfile?.shortPublicKey ?: "Unknown")
|
||||
}
|
||||
FilledIconButton(
|
||||
onClick = {
|
||||
dismissAndRun { navigator.navigate(Screen.MyQr) }
|
||||
},
|
||||
shape = MaterialShapes.Square.toShape()
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_qr),
|
||||
contentDescription = "My QR"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.size(16.dp))
|
||||
BottomMenuList(
|
||||
onDismiss = dismissAndRun,
|
||||
accountViewModel = accountViewModel,
|
||||
chatViewModel = chatViewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show the relay setup dialog if the msg relay list is empty
|
||||
if (isRelayListEmpty) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { accountViewModel.dismissRelayWarning() },
|
||||
sheetState = sheetState,
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(0.5f)
|
||||
.padding(horizontal = 24.dp)
|
||||
.navigationBarsPadding(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Messaging Relays are missing",
|
||||
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontFamily = getExpressiveFontFamily()
|
||||
),
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(24.dp),
|
||||
shape = MaterialShapes.Circle.toShape(),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_close),
|
||||
contentDescription = "X",
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.onError
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = "Other people won't be able to send you messages.",
|
||||
style = MaterialTheme.typography.titleSmallEmphasized,
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(24.dp),
|
||||
shape = MaterialShapes.Circle.toShape(),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_close),
|
||||
contentDescription = "X",
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.onError
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = "You cannot store your messages.",
|
||||
style = MaterialTheme.typography.titleSmallEmphasized,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "Please click the button below to continue with the default set of relays. You can always change them later in the settings.",
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontStyle = FontStyle.Italic,
|
||||
),
|
||||
)
|
||||
Text(
|
||||
text = "If you believe this is a mistake, please click the Retry button to check again.",
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontStyle = FontStyle.Italic,
|
||||
),
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
if (isBusy) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
LoadingIndicator()
|
||||
}
|
||||
} else {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
TextButton(
|
||||
enabled = !isBusy,
|
||||
onClick = {
|
||||
isBusy = true
|
||||
accountViewModel.refetchMsgRelays()
|
||||
isBusy = false
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(ButtonDefaults.MediumContainerHeight),
|
||||
) {
|
||||
Text(
|
||||
text = "Retry",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
)
|
||||
}
|
||||
Button(
|
||||
enabled = !isBusy,
|
||||
onClick = {
|
||||
accountViewModel.useDefaultMsgRelayList()
|
||||
scope.launch { sheetState.hide() }
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(ButtonDefaults.MediumContainerHeight),
|
||||
) {
|
||||
Text(
|
||||
text = "Use Default",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NewRequests(requests: List<Room>) {
|
||||
val navigator = LocalNavigator.current
|
||||
val profileCache = LocalProfileCache.current
|
||||
|
||||
val total = requests.size
|
||||
val firstRoom = requests.getOrNull(0)
|
||||
val secondRoom = requests.getOrNull(1)
|
||||
|
||||
val firstRoomState by remember(firstRoom?.id) {
|
||||
firstRoom?.uiStateFlow(profileCache) ?: flowOf(RoomUiState())
|
||||
}.collectAsStateWithLifecycle(RoomUiState())
|
||||
|
||||
val secondRoomState by remember(secondRoom?.id) {
|
||||
(secondRoom ?: firstRoom)?.uiStateFlow(profileCache) ?: flowOf(RoomUiState())
|
||||
}.collectAsStateWithLifecycle(RoomUiState())
|
||||
|
||||
val supportingText = when {
|
||||
total == 1 && firstRoom != null -> {
|
||||
val message = firstRoom.lastMessage ?: ""
|
||||
"${firstRoomState.name}: $message"
|
||||
}
|
||||
|
||||
total == 2 -> {
|
||||
"${firstRoomState.name} and ${secondRoomState.name}"
|
||||
}
|
||||
|
||||
total > 2 -> {
|
||||
val othersCount = total - 2
|
||||
val othersText = if (othersCount == 1) "1 other" else "$othersCount others"
|
||||
"${firstRoomState.name}, ${secondRoomState.name} and $othersText"
|
||||
}
|
||||
|
||||
else -> ""
|
||||
}
|
||||
|
||||
val totalUnread = requests.sumOf { it.unreadCount }
|
||||
|
||||
ListItem(
|
||||
modifier = Modifier.clickable {
|
||||
navigator.navigate(Screen.RequestList)
|
||||
},
|
||||
leadingContent = {
|
||||
BadgedBox(
|
||||
badge = {
|
||||
if (totalUnread > 0) {
|
||||
Badge {
|
||||
Text(totalUnread.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(MaterialShapes.Clover4Leaf.toShape()),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(48.dp),
|
||||
color = MaterialTheme.colorScheme.tertiaryContainer,
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_request),
|
||||
contentDescription = "Requests",
|
||||
tint = MaterialTheme.colorScheme.onTertiaryFixed
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = "Requests",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized.copy(
|
||||
fontWeight = if (totalUnread > 0) FontWeight.SemiBold else FontWeight.Normal
|
||||
)
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
if (supportingText.isNotEmpty()) {
|
||||
Text(
|
||||
text = supportingText,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontWeight = if (totalUnread > 0) FontWeight.SemiBold else FontWeight.Normal,
|
||||
color = if (totalUnread > 0) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.outline
|
||||
),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = ListItemDefaults.colors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun ChatRoom(room: Room, onClick: () -> Unit) {
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
val displayName by remember(room) { room.displayNameFlow(viewModel) }.collectAsState("Loading...")
|
||||
val picture by remember(room) { room.pictureFlow(viewModel) }.collectAsState(null)
|
||||
val profileCache = LocalProfileCache.current
|
||||
val roomState by remember(room.id) { room.uiStateFlow(profileCache) }
|
||||
.collectAsStateWithLifecycle(RoomUiState())
|
||||
|
||||
ListItem(
|
||||
modifier = Modifier.clickable(onClick = onClick),
|
||||
leadingContent = {
|
||||
Avatar(picture = picture, description = displayName)
|
||||
BadgedBox(
|
||||
badge = {
|
||||
if (room.unreadCount > 0) {
|
||||
Badge {
|
||||
Text(room.unreadCount.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Avatar(picture = roomState.picture, description = roomState.picture)
|
||||
}
|
||||
},
|
||||
headlineContent = {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = displayName,
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
text = roomState.name,
|
||||
style = MaterialTheme.typography.titleMediumEmphasized.copy(
|
||||
fontWeight = if (room.unreadCount > 0) FontWeight.SemiBold else FontWeight.Normal
|
||||
),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(
|
||||
text = room.createdAt.ago(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
textAlign = TextAlign.End,
|
||||
)
|
||||
}
|
||||
},
|
||||
supportingContent = {
|
||||
if (!room.lastMessage.isNullOrBlank()) {
|
||||
Text(
|
||||
text = room.lastMessage!!,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
text = room.lastMessage ?: "",
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontWeight = if (room.unreadCount > 0) FontWeight.SemiBold else FontWeight.Normal,
|
||||
color = if (room.unreadCount > 0) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.outline
|
||||
),
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
},
|
||||
@@ -396,16 +785,17 @@ fun ChatRoom(room: Room, onClick: () -> Unit) {
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun BottomMenuList(
|
||||
onDismiss: (suspend () -> Unit) -> Unit
|
||||
onDismiss: (suspend () -> Unit) -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
chatViewModel: ChatViewModel,
|
||||
) {
|
||||
val navController = LocalNavController.current
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
val navigator = LocalNavigator.current
|
||||
|
||||
val defaultMenuList = listOf(
|
||||
"Relay Management" to { navController.navigate(Screen.Relay) },
|
||||
"Spams & Blocks" to { },
|
||||
"Contacts" to { },
|
||||
"Settings" to { }
|
||||
"Update Profile" to { navigator.navigate(Screen.UpdateProfile) },
|
||||
"Contact List" to { navigator.navigate(Screen.ContactList) },
|
||||
"Relay Management" to { navigator.navigate(Screen.Relay) },
|
||||
"Settings" to { navigator.navigate(Screen.Settings) }
|
||||
)
|
||||
|
||||
Column(
|
||||
@@ -429,7 +819,14 @@ fun BottomMenuList(
|
||||
}
|
||||
Spacer(modifier = Modifier.size(16.dp))
|
||||
FilledTonalButton(
|
||||
onClick = { viewModel.logout() },
|
||||
onClick = {
|
||||
onDismiss {
|
||||
accountViewModel.logout(onLogout = {
|
||||
accountViewModel.resetInternalState()
|
||||
chatViewModel.resetInternalState()
|
||||
})
|
||||
}
|
||||
},
|
||||
colors = ButtonDefaults.filledTonalButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error,
|
||||
contentColor = MaterialTheme.colorScheme.onError
|
||||
|
||||
@@ -7,11 +7,14 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
@@ -19,7 +22,6 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LoadingIndicator
|
||||
import androidx.compose.material3.MaterialShapes
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
@@ -27,87 +29,75 @@ import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.toShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.ic_arrow_back
|
||||
import coop.composeapp.generated.resources.ic_scanner
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import rust.nostr.sdk.Keys
|
||||
import rust.nostr.sdk.NostrConnectUri
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import su.reya.coop.LocalNavController
|
||||
import su.reya.coop.LocalNostrViewModel
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalScanResult
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
import su.reya.coop.Screen
|
||||
import su.reya.coop.shared.Avatar
|
||||
import su.reya.coop.short
|
||||
import su.reya.coop.viewmodel.AccountViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun ImportScreen(
|
||||
isLoading: Boolean,
|
||||
onBack: () -> Unit,
|
||||
onSave: (secret: String) -> Unit
|
||||
) {
|
||||
fun ImportScreen(viewModel: AccountViewModel) {
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val navController = LocalNavController.current
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val navigator = LocalNavigator.current
|
||||
val qrScanResult = LocalScanResult.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
val accountState by viewModel.state.collectAsStateWithLifecycle()
|
||||
var secret by remember { mutableStateOf("") }
|
||||
var pubkey by remember { mutableStateOf<PublicKey?>(null) }
|
||||
val metadata by remember(pubkey) {
|
||||
if (pubkey != null) {
|
||||
viewModel.getMetadata(pubkey!!)
|
||||
} else {
|
||||
MutableStateFlow(null)
|
||||
}
|
||||
}.collectAsState(null)
|
||||
var password by remember { mutableStateOf("") }
|
||||
var requirePassword by remember { mutableStateOf(false) }
|
||||
|
||||
|
||||
val profile = metadata?.asRecord()
|
||||
val displayName = profile?.displayName ?: profile?.name ?: pubkey?.short() ?: "Unknown"
|
||||
val picture = profile?.picture
|
||||
|
||||
val savedStateHandle = navController.currentBackStackEntry?.savedStateHandle
|
||||
val qrResult by savedStateHandle
|
||||
?.getStateFlow<String?>("qr_result", null)
|
||||
?.collectAsState()
|
||||
?: remember { mutableStateOf(null) }
|
||||
|
||||
LaunchedEffect(qrResult) {
|
||||
qrResult?.let { result ->
|
||||
LaunchedEffect(qrScanResult.content) {
|
||||
qrScanResult.content?.let { result ->
|
||||
runCatching {
|
||||
if (result.startsWith("nsec")) {
|
||||
if (result.startsWith("nsec1") || result.startsWith("ncryptsec1")) {
|
||||
Keys.parse(result)
|
||||
} else if (result.startsWith("bunker://")) {
|
||||
NostrConnectUri.parse(result)
|
||||
} else {
|
||||
throw IllegalArgumentException("Invalid secret: $result")
|
||||
throw IllegalArgumentException("Unsupported secret format")
|
||||
}
|
||||
}.onSuccess {
|
||||
secret = result
|
||||
}.onFailure { e ->
|
||||
e.message?.let { snackbarHostState.showSnackbar(it) }
|
||||
}
|
||||
.onSuccess { it -> secret = result }
|
||||
.onFailure { e -> println("Failed to parse QR: ${e.message}") }
|
||||
// Clear the nav state
|
||||
navController.currentBackStackEntry?.savedStateHandle?.remove<String>("qr_result")
|
||||
qrScanResult.clear()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(secret) {
|
||||
if (secret.startsWith("ncryptsec1")) {
|
||||
requirePassword = true
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate to Home on successful import (signerRequired becomes false)
|
||||
LaunchedEffect(accountState.signerRequired) {
|
||||
if (accountState.signerRequired == false) {
|
||||
navigator.navigate(Screen.Home)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,7 +116,7 @@ fun ImportScreen(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
IconButton(onClick = { navigator.goBack() }) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||
contentDescription = "Back"
|
||||
@@ -134,7 +124,7 @@ fun ImportScreen(
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { navController.navigate(Screen.Scan) }) {
|
||||
IconButton(onClick = { navigator.navigate(Screen.Scan) }) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_scanner),
|
||||
contentDescription = "Scanner"
|
||||
@@ -145,105 +135,123 @@ fun ImportScreen(
|
||||
},
|
||||
content = { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = innerPadding.calculateTopPadding())
|
||||
.imePadding(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.padding(top = innerPadding.calculateTopPadding()),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(120.dp)
|
||||
.clip(MaterialShapes.Pentagon.toShape()),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Avatar(
|
||||
picture = picture,
|
||||
description = "Profile picture",
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
shape = MaterialShapes.Pentagon.toShape(),
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Text(
|
||||
text = displayName,
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.titleLargeEmphasized,
|
||||
)
|
||||
}
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
.fillMaxWidth()
|
||||
.weight(1f, fill = false),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
.padding(24.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Enter your Secret Key or Bunker URI:",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized.copy(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
)
|
||||
BasicTextField(
|
||||
value = secret,
|
||||
onValueChange = { secret = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxLines = 4,
|
||||
visualTransformation = PasswordVisualTransformation('*'),
|
||||
textStyle = MaterialTheme.typography.bodyMediumEmphasized.copy(
|
||||
color = MaterialTheme.colorScheme.primaryFixed,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.secondary),
|
||||
decorationBox = { innerTextField ->
|
||||
Box(contentAlignment = Alignment.CenterStart) {
|
||||
if (secret.isEmpty()) {
|
||||
Text(
|
||||
"bunker://",
|
||||
style = MaterialTheme.typography.bodyMediumEmphasized.copy(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
|
||||
alpha = 0.5f
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Enter your Secret Key or Bunker URI:",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized.copy(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
)
|
||||
BasicTextField(
|
||||
value = secret,
|
||||
onValueChange = { secret = it },
|
||||
enabled = !accountState.isImporting,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
imeAction = ImeAction.Done,
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
focusManager.clearFocus()
|
||||
}
|
||||
),
|
||||
visualTransformation = PasswordVisualTransformation('*'),
|
||||
textStyle = MaterialTheme.typography.bodyMediumEmphasized.copy(
|
||||
color = MaterialTheme.colorScheme.tertiaryFixedDim,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.tertiaryContainer),
|
||||
decorationBox = { innerTextField ->
|
||||
Box(contentAlignment = Alignment.CenterStart) {
|
||||
if (secret.isEmpty()) {
|
||||
Text(
|
||||
"bunker://",
|
||||
style = MaterialTheme.typography.bodyMediumEmphasized.copy(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
|
||||
alpha = 0.5f
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
innerTextField()
|
||||
}
|
||||
innerTextField()
|
||||
}
|
||||
)
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
if (requirePassword) {
|
||||
Text(
|
||||
text = "Decrypt Password:",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized.copy(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
)
|
||||
BasicTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
enabled = !accountState.isImporting && requirePassword,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
imeAction = ImeAction.Done,
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
focusManager.clearFocus()
|
||||
}
|
||||
),
|
||||
visualTransformation = PasswordVisualTransformation('*'),
|
||||
textStyle = MaterialTheme.typography.bodyMediumEmphasized.copy(
|
||||
color = MaterialTheme.colorScheme.tertiaryFixedDim,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.tertiaryContainer),
|
||||
decorationBox = { innerTextField ->
|
||||
Box(contentAlignment = Alignment.CenterStart) {
|
||||
innerTextField()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
Spacer(modifier = Modifier.size(16.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
if (pubkey == null) {
|
||||
scope.launch {
|
||||
viewModel.verifyIdentity(secret).let { pubkey = it }
|
||||
}
|
||||
} else {
|
||||
onSave(secret)
|
||||
}
|
||||
viewModel.importIdentity(secret, password)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(ButtonDefaults.MediumContainerHeight),
|
||||
enabled = secret.isNotBlank() && !isLoading,
|
||||
enabled = secret.isNotBlank() && !accountState.isImporting,
|
||||
) {
|
||||
if (isLoading) {
|
||||
if (accountState.isImporting) {
|
||||
LoadingIndicator()
|
||||
} else {
|
||||
Text(
|
||||
text = if (pubkey == null) "Verify" else "Continue",
|
||||
text = "Continue",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,22 +13,23 @@ import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.ic_arrow_back
|
||||
import io.github.alexzhirkevich.qrose.rememberQrCodePainter
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import su.reya.coop.LocalNostrViewModel
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
import su.reya.coop.viewmodel.AccountViewModel
|
||||
|
||||
@Composable
|
||||
fun MyQrScreen(
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
fun MyQrScreen(viewModel: AccountViewModel) {
|
||||
val navigator = LocalNavigator.current
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
val currentUser = viewModel.currentUser() ?: return
|
||||
val currentUser by viewModel.currentUserProfile.collectAsStateWithLifecycle()
|
||||
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
@@ -41,7 +42,7 @@ fun MyQrScreen(
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
IconButton(onClick = { navigator.goBack() }) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||
contentDescription = "Back"
|
||||
@@ -59,7 +60,7 @@ fun MyQrScreen(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Image(
|
||||
painter = rememberQrCodePainter(currentUser.toBech32()),
|
||||
painter = rememberQrCodePainter(currentUser?.publicKey?.toBech32() ?: ""),
|
||||
contentDescription = "My QR"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.ic_arrow_back
|
||||
import coop.composeapp.generated.resources.ic_arrow_next
|
||||
@@ -54,37 +55,37 @@ import coop.composeapp.generated.resources.ic_scanner
|
||||
import kotlinx.coroutines.delay
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import su.reya.coop.LocalNavController
|
||||
import su.reya.coop.LocalNostrViewModel
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalProfileCache
|
||||
import su.reya.coop.LocalScanResult
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
import su.reya.coop.Screen
|
||||
import su.reya.coop.shared.Avatar
|
||||
import su.reya.coop.short
|
||||
import su.reya.coop.viewmodel.AccountViewModel
|
||||
import su.reya.coop.viewmodel.ChatViewModel
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun NewChatScreen(
|
||||
onBack: () -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
chatViewModel: ChatViewModel
|
||||
) {
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val navController = LocalNavController.current
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
val navigator = LocalNavigator.current
|
||||
val qrScanResult = LocalScanResult.current
|
||||
val accountState by accountViewModel.state.collectAsStateWithLifecycle()
|
||||
val contactList = accountState.contactList
|
||||
var query by remember { mutableStateOf("") }
|
||||
|
||||
val contactList by viewModel.contactList.collectAsState(initial = emptySet())
|
||||
val createGroup = remember { mutableStateOf(false) }
|
||||
val searchResults = remember { mutableStateListOf<PublicKey>() }
|
||||
val selectedReceivers = remember { mutableStateListOf<PublicKey>() }
|
||||
var query by remember { mutableStateOf("") }
|
||||
|
||||
val savedStateHandle = navController.currentBackStackEntry?.savedStateHandle
|
||||
val qrResult by savedStateHandle
|
||||
?.getStateFlow<String?>("qr_result", null)
|
||||
?.collectAsState()
|
||||
?: remember { mutableStateOf(null) }
|
||||
|
||||
LaunchedEffect(query) {
|
||||
if (query.length >= 3) {
|
||||
delay(500) // 500ms debounce
|
||||
delay(500.milliseconds)
|
||||
|
||||
if (query.startsWith("npub1")) {
|
||||
val pubkey = try {
|
||||
@@ -97,27 +98,34 @@ fun NewChatScreen(
|
||||
selectedReceivers.add(pubkey)
|
||||
}
|
||||
} else if (query.contains("@")) {
|
||||
val pubkey = viewModel.searchByAddress(query)
|
||||
if (pubkey != null) {
|
||||
selectedReceivers.add(pubkey)
|
||||
accountViewModel.searchByAddress(query) { pubkey ->
|
||||
if (pubkey != null) {
|
||||
selectedReceivers.add(pubkey)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val results = viewModel.searchByNostr(query)
|
||||
searchResults.clear()
|
||||
searchResults.addAll(results)
|
||||
accountViewModel.searchByNostr(query) { results ->
|
||||
searchResults.clear()
|
||||
searchResults.addAll(results)
|
||||
}
|
||||
}
|
||||
|
||||
query = ""
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(qrResult) {
|
||||
qrResult?.let { result ->
|
||||
runCatching { PublicKey.parse(result) }
|
||||
.onSuccess { pubkey -> selectedReceivers.add(pubkey) }
|
||||
.onFailure { e -> println("Failed to parse QR: ${e.message}") }
|
||||
LaunchedEffect(qrScanResult.content) {
|
||||
qrScanResult.content?.let { result ->
|
||||
// Verify the content
|
||||
runCatching {
|
||||
PublicKey.parse(result)
|
||||
}.onSuccess { pubkey ->
|
||||
selectedReceivers.add(pubkey)
|
||||
}.onFailure { e ->
|
||||
snackbarHostState.showSnackbar("Invalid address: ${e.message}")
|
||||
}
|
||||
// Clear the nav state
|
||||
navController.currentBackStackEntry?.savedStateHandle?.remove<String>("qr_result")
|
||||
qrScanResult.clear()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,7 +144,7 @@ fun NewChatScreen(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
IconButton(onClick = { navigator.goBack() }) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||
contentDescription = "Back"
|
||||
@@ -144,7 +152,7 @@ fun NewChatScreen(
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { navController.navigate(Screen.Scan) }) {
|
||||
IconButton(onClick = { navigator.navigate(Screen.Scan) }) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_scanner),
|
||||
contentDescription = "Scanner"
|
||||
@@ -167,8 +175,8 @@ fun NewChatScreen(
|
||||
) {
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = {
|
||||
val roomId = viewModel.createChatRoom(selectedReceivers.toList())
|
||||
navController.navigate(Screen.Chat(roomId))
|
||||
val roomId = chatViewModel.createChatRoom(selectedReceivers.toList())
|
||||
navigator.navigate(Screen.Chat(roomId))
|
||||
},
|
||||
expanded = false,
|
||||
icon = {
|
||||
@@ -178,8 +186,8 @@ fun NewChatScreen(
|
||||
)
|
||||
},
|
||||
text = { Text("Next") },
|
||||
containerColor = MaterialTheme.colorScheme.tertiary,
|
||||
contentColor = MaterialTheme.colorScheme.onTertiary,
|
||||
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -258,8 +266,8 @@ fun NewChatScreen(
|
||||
items = searchResults,
|
||||
selectedReceivers = selectedReceivers,
|
||||
onContactClick = { pubkey ->
|
||||
val roomId = viewModel.createChatRoom(listOf(pubkey))
|
||||
navController.navigate(Screen.Chat(roomId))
|
||||
val roomId = chatViewModel.createChatRoom(listOf(pubkey))
|
||||
navigator.navigate(Screen.Chat(roomId))
|
||||
},
|
||||
)
|
||||
Spacer(modifier = Modifier.size(16.dp))
|
||||
@@ -269,8 +277,8 @@ fun NewChatScreen(
|
||||
items = contactList.toList(),
|
||||
selectedReceivers = selectedReceivers,
|
||||
onContactClick = { pubkey ->
|
||||
val roomId = viewModel.createChatRoom(listOf(pubkey))
|
||||
navController.navigate(Screen.Chat(roomId))
|
||||
val roomId = chatViewModel.createChatRoom(listOf(pubkey))
|
||||
navigator.navigate(Screen.Chat(roomId))
|
||||
}
|
||||
)
|
||||
Spacer(modifier = Modifier.size(16.dp))
|
||||
@@ -286,13 +294,9 @@ fun ReceiverChip(
|
||||
pubkey: PublicKey,
|
||||
onRemove: () -> Unit
|
||||
) {
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
val metadataFlow = remember(pubkey) { viewModel.getMetadata(pubkey) }
|
||||
val metadata by metadataFlow.collectAsState(initial = null)
|
||||
|
||||
val profile = metadata?.asRecord()
|
||||
val displayName = profile?.name ?: profile?.displayName ?: pubkey.short()
|
||||
val picture = profile?.picture
|
||||
val profileCache = LocalProfileCache.current
|
||||
val profileFlow = remember(pubkey) { profileCache.getMetadata(pubkey) }
|
||||
val profile by profileFlow.collectAsState(initial = null)
|
||||
|
||||
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) {
|
||||
InputChip(
|
||||
@@ -300,7 +304,7 @@ fun ReceiverChip(
|
||||
onClick = onRemove,
|
||||
label = {
|
||||
Text(
|
||||
text = displayName,
|
||||
text = profile?.name ?: "No name",
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
@@ -308,8 +312,8 @@ fun ReceiverChip(
|
||||
},
|
||||
avatar = {
|
||||
Avatar(
|
||||
picture = picture,
|
||||
description = displayName,
|
||||
picture = profile?.picture,
|
||||
description = profile?.name ?: "No name",
|
||||
size = 24.dp
|
||||
)
|
||||
},
|
||||
@@ -372,13 +376,9 @@ fun ContactListItem(
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit
|
||||
) {
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
val metadataFlow = remember(pubkey) { viewModel.getMetadata(pubkey) }
|
||||
val metadata by metadataFlow.collectAsState(initial = null)
|
||||
|
||||
val profile = metadata?.asRecord()
|
||||
val displayName = profile?.name ?: profile?.displayName ?: pubkey.short()
|
||||
val picture = profile?.picture
|
||||
val profileCache = LocalProfileCache.current
|
||||
val profileFlow = remember(pubkey) { profileCache.getMetadata(pubkey) }
|
||||
val profile by profileFlow.collectAsState(initial = null)
|
||||
|
||||
SegmentedListItem(
|
||||
selected = isSelected,
|
||||
@@ -390,15 +390,15 @@ fun ContactListItem(
|
||||
),
|
||||
leadingContent = {
|
||||
Avatar(
|
||||
picture = picture,
|
||||
description = displayName,
|
||||
picture = profile?.picture,
|
||||
description = profile?.name ?: "",
|
||||
size = 36.dp
|
||||
)
|
||||
},
|
||||
supportingContent = { Text(text = pubkey.short()) },
|
||||
content = {
|
||||
Text(
|
||||
text = displayName,
|
||||
text = profile?.name ?: "Unknown",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,245 +1,26 @@
|
||||
package su.reya.coop.screens
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LoadingIndicator
|
||||
import androidx.compose.material3.MaterialShapes
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.toShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.ic_arrow_back
|
||||
import coop.composeapp.generated.resources.ic_plus
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.Screen
|
||||
import su.reya.coop.shared.ProfileEditor
|
||||
import su.reya.coop.viewmodel.AccountViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun NewIdentityScreen(
|
||||
isLoading: Boolean,
|
||||
onBack: () -> Unit,
|
||||
onSave: (name: String, bio: String?, picture: Uri?) -> Unit
|
||||
) {
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
var name by remember { mutableStateOf("") }
|
||||
var bio by remember { mutableStateOf("") }
|
||||
var picture by remember { mutableStateOf<Uri?>(null) }
|
||||
fun NewIdentityScreen(viewModel: AccountViewModel) {
|
||||
val navigator = LocalNavigator.current
|
||||
val accountState by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.GetContent()
|
||||
) { uri: Uri? ->
|
||||
picture = uri
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = "Create a new identity",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||
contentDescription = "Back"
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
)
|
||||
)
|
||||
},
|
||||
content = { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.padding(top = innerPadding.calculateTopPadding()),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(120.dp)
|
||||
.clip(MaterialShapes.Pentagon.toShape())
|
||||
.clickable { launcher.launch("image/*") },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (picture != null) {
|
||||
AsyncImage(
|
||||
model = picture,
|
||||
contentDescription = "Profile picture",
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
} else {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_plus),
|
||||
contentDescription = "Pick avatar",
|
||||
modifier = Modifier.size(48.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "What others should call you?",
|
||||
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
)
|
||||
BasicTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxLines = 1,
|
||||
textStyle = MaterialTheme.typography.headlineLargeEmphasized.copy(
|
||||
color = MaterialTheme.colorScheme.primaryFixed,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.secondary),
|
||||
decorationBox = { innerTextField ->
|
||||
Box(contentAlignment = Alignment.CenterStart) {
|
||||
if (name.isEmpty()) {
|
||||
Text(
|
||||
"Alice",
|
||||
style = MaterialTheme.typography.headlineLargeEmphasized.copy(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
|
||||
alpha = 0.5f
|
||||
)
|
||||
)
|
||||
}
|
||||
innerTextField()
|
||||
}
|
||||
}
|
||||
)
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Text(
|
||||
text = "Your bio (optional)",
|
||||
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
)
|
||||
BasicTextField(
|
||||
value = bio,
|
||||
onValueChange = { bio = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxLines = 3,
|
||||
textStyle = MaterialTheme.typography.bodyLarge.copy(
|
||||
color = MaterialTheme.colorScheme.primaryFixed,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.secondary),
|
||||
decorationBox = { innerTextField ->
|
||||
Box(contentAlignment = Alignment.CenterStart) {
|
||||
if (bio.isEmpty()) {
|
||||
Text(
|
||||
"I love cat",
|
||||
style = MaterialTheme.typography.headlineLargeEmphasized.copy(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
|
||||
alpha = 0.5f
|
||||
)
|
||||
)
|
||||
}
|
||||
innerTextField()
|
||||
}
|
||||
}
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Button(
|
||||
onClick = {
|
||||
onSave(name, bio, picture)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(ButtonDefaults.MediumContainerHeight),
|
||||
enabled = name.isNotBlank() && !isLoading,
|
||||
) {
|
||||
if (isLoading) {
|
||||
LoadingIndicator()
|
||||
} else {
|
||||
Text(
|
||||
text = "Continue",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ProfileEditor(
|
||||
title = "Create a new identity",
|
||||
buttonLabel = "Continue",
|
||||
isBusy = accountState.isImporting,
|
||||
onBack = { navigator.goBack() },
|
||||
onConfirm = { name, bio, bytes, type ->
|
||||
viewModel.createIdentity(name, bio, bytes, type)
|
||||
navigator.navigate(Screen.Home)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package su.reya.coop.screens
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -9,16 +10,23 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarResult
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
@@ -26,27 +34,85 @@ import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.drawscope.rotate
|
||||
import androidx.compose.ui.graphics.drawscope.translate
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.LinkAnnotation
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextLinkStyles
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.coop
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
import su.reya.coop.Screen
|
||||
import su.reya.coop.shared.getExpressiveFontFamily
|
||||
import su.reya.coop.viewmodel.AccountViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun OnboardingScreen(onOpenImport: () -> Unit, onOpenNew: () -> Unit) {
|
||||
fun OnboardingScreen(viewModel: AccountViewModel) {
|
||||
val context = LocalContext.current
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val navigator = LocalNavigator.current
|
||||
val accountState by viewModel.state.collectAsStateWithLifecycle()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Navigate to Home on successful external signer connection
|
||||
LaunchedEffect(accountState.signerRequired) {
|
||||
if (accountState.signerRequired == false) {
|
||||
navigator.navigate(Screen.Home)
|
||||
}
|
||||
}
|
||||
|
||||
val logoPainter = painterResource(Res.drawable.coop)
|
||||
val expressiveFont = getExpressiveFontFamily()
|
||||
|
||||
val annotatedText = buildAnnotatedString {
|
||||
append("By using Coop, you agree to accept\nour ")
|
||||
// Push "Terms of Use" link
|
||||
pushLink(
|
||||
LinkAnnotation.Url(
|
||||
url = "https://coop.free/terms",
|
||||
styles = TextLinkStyles(
|
||||
style = SpanStyle(
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
append("Terms of Use")
|
||||
pop()
|
||||
append(" and ")
|
||||
// Push "Privacy Policy" link
|
||||
pushLink(
|
||||
LinkAnnotation.Url(
|
||||
url = "https://coop.free/privacy",
|
||||
styles = TextLinkStyles(
|
||||
style = SpanStyle(
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
append("Privacy Policy")
|
||||
pop()
|
||||
append(".")
|
||||
}
|
||||
|
||||
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
content = { innerPadding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(bottom = innerPadding.calculateBottomPadding())
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
LogoRepeatingBackground(
|
||||
painter = logoPainter,
|
||||
logosPerRow = 6,
|
||||
@@ -54,55 +120,105 @@ fun OnboardingScreen(onOpenImport: () -> Unit, onOpenNew: () -> Unit) {
|
||||
horizontalOffset = 0.5f
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(bottom = innerPadding.calculateBottomPadding() + 16.dp),
|
||||
) {
|
||||
Box(
|
||||
Spacer(modifier = Modifier.weight(2f))
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.weight(2f)
|
||||
.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
// TODO: Add headline
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = innerPadding.calculateBottomPadding()),
|
||||
contentAlignment = Alignment.BottomEnd,
|
||||
.padding(24.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
shadowElevation = 4.dp,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = innerPadding.calculateBottomPadding()),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Get Started",
|
||||
style = MaterialTheme.typography.headlineSmallEmphasized.copy(
|
||||
fontFamily = expressiveFont,
|
||||
),
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Text(
|
||||
text = "Coop is a secure and easy to use messaging app. All your communications are encrypted and private by default.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.size(24.dp))
|
||||
Button(
|
||||
onClick = onOpenNew,
|
||||
onClick = { navigator.navigate(Screen.NewIdentity) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.size(ButtonDefaults.LargeContainerHeight),
|
||||
.size(ButtonDefaults.MediumContainerHeight),
|
||||
) {
|
||||
Text(
|
||||
text = "Start messaging",
|
||||
style = MaterialTheme.typography.titleLargeEmphasized,
|
||||
text = "Start Messaging",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.size(16.dp))
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
FilledTonalButton(
|
||||
onClick = onOpenImport,
|
||||
onClick = {
|
||||
if (viewModel.isExternalSignerAvailable()) {
|
||||
// Connect to the external signer
|
||||
// TODO: show all available signers?
|
||||
viewModel.connectExternalSigner()
|
||||
} else {
|
||||
scope.launch {
|
||||
val result = snackbarHostState.showSnackbar(
|
||||
message = "External signer not installed. Please install Amber or alternatives.",
|
||||
actionLabel = "Install",
|
||||
withDismissAction = true,
|
||||
duration = SnackbarDuration.Long
|
||||
)
|
||||
if (result == SnackbarResult.ActionPerformed) {
|
||||
val intent = Intent(
|
||||
Intent.ACTION_VIEW,
|
||||
"https://zapstore.dev/apps/com.greenart7c3.nostrsigner".toUri()
|
||||
)
|
||||
context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(ButtonDefaults.LargeContainerHeight),
|
||||
colors = ButtonDefaults.filledTonalButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onTertiaryContainer
|
||||
),
|
||||
.height(ButtonDefaults.MediumContainerHeight),
|
||||
) {
|
||||
Text(
|
||||
text = "Import identity",
|
||||
style = MaterialTheme.typography.titleLargeEmphasized,
|
||||
text = "Connect with Amber",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
TextButton(
|
||||
onClick = { navigator.navigate(Screen.Import) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(ButtonDefaults.MediumContainerHeight),
|
||||
) {
|
||||
Text(
|
||||
text = "Add an Existing Identity",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = annotatedText,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -116,7 +232,7 @@ fun LogoRepeatingBackground(
|
||||
rotationDegrees: Float = 0f,
|
||||
horizontalOffset: Float = 0.5f
|
||||
) {
|
||||
val tintColor = MaterialTheme.colorScheme.primary
|
||||
val tintColor = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
val canvasWidth = size.width
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
package su.reya.coop.screens
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialShapes
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SegmentedListItem
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.toShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.ic_arrow_back
|
||||
import coop.composeapp.generated.resources.ic_chat
|
||||
import coop.composeapp.generated.resources.ic_share
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalProfileCache
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
import su.reya.coop.Screen
|
||||
import su.reya.coop.shared.Avatar
|
||||
import su.reya.coop.shared.getExpressiveFontFamily
|
||||
import su.reya.coop.short
|
||||
import su.reya.coop.viewmodel.ChatViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun ProfileScreen(
|
||||
pubkey: String,
|
||||
chatViewModel: ChatViewModel
|
||||
) {
|
||||
val pubkey = runCatching { PublicKey.parse(pubkey) }.getOrNull() ?: return
|
||||
|
||||
val context = LocalContext.current
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val navigator = LocalNavigator.current
|
||||
val profileCache = LocalProfileCache.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val profileFlow = remember(pubkey) { profileCache.getMetadata(pubkey) }
|
||||
val profile by profileFlow.collectAsStateWithLifecycle()
|
||||
|
||||
val metadata = profile?.metadata?.asRecord()
|
||||
val nip05 = metadata?.nip05 ?: pubkey.short()
|
||||
val picture = metadata?.picture
|
||||
|
||||
val details = remember(profile) {
|
||||
listOf(
|
||||
"Username:" to (metadata?.name ?: "None"),
|
||||
"Website:" to (metadata?.website ?: "None"),
|
||||
"₿ Lightning Address:" to (metadata?.lud16 ?: "None"),
|
||||
)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { /* empty */ },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navigator.goBack() }) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||
contentDescription = "Back"
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
)
|
||||
)
|
||||
},
|
||||
content = { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.weight(1f),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(120.dp)
|
||||
.clip(MaterialShapes.Cookie9Sided.toShape()),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Avatar(
|
||||
picture = picture,
|
||||
description = "Profile picture",
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
shape = MaterialShapes.Cookie9Sided.toShape(),
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = profile?.name ?: "No name",
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||
fontFamily = getExpressiveFontFamily()
|
||||
),
|
||||
)
|
||||
Text(
|
||||
text = nip05,
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.size(24.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
FilledTonalIconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
try {
|
||||
val roomId =
|
||||
chatViewModel.createChatRoom(listOf(pubkey))
|
||||
navigator.navigate(Screen.Chat(roomId))
|
||||
} catch (e: Exception) {
|
||||
e.message?.let { snackbarHostState.showSnackbar(it) }
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(ButtonDefaults.MediumContainerHeight),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_chat),
|
||||
contentDescription = "New Chat"
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "Message",
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
FilledTonalIconButton(
|
||||
onClick = {
|
||||
val sendIntent = Intent().apply {
|
||||
action = Intent.ACTION_SEND
|
||||
putExtra(Intent.EXTRA_TEXT, pubkey.toBech32())
|
||||
type = "text/plain"
|
||||
}
|
||||
val shareIntent = Intent.createChooser(sendIntent, null)
|
||||
context.startActivity(shareIntent)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(ButtonDefaults.MediumContainerHeight),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_share),
|
||||
contentDescription = "Share"
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "Share",
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.weight(1.5f),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap),
|
||||
) {
|
||||
details.forEachIndexed { index, (label, value) ->
|
||||
SegmentedListItem(
|
||||
onClick = { },
|
||||
shapes = ListItemDefaults.segmentedShapes(
|
||||
index = index,
|
||||
count = details.size
|
||||
),
|
||||
content = { Text(label) },
|
||||
supportingContent = { Text(value) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -3,48 +3,80 @@ package su.reya.coop.screens
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.selection.selectableGroup
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.PlainTooltip
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SegmentedListItem
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TooltipAnchorPosition
|
||||
import androidx.compose.material3.TooltipBox
|
||||
import androidx.compose.material3.TooltipDefaults
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberTooltipState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.ic_arrow_back
|
||||
import coop.composeapp.generated.resources.ic_check
|
||||
import coop.composeapp.generated.resources.ic_close
|
||||
import coop.composeapp.generated.resources.ic_plus
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import rust.nostr.sdk.RelayMetadata
|
||||
import rust.nostr.sdk.RelayUrl
|
||||
import su.reya.coop.LocalNostrViewModel
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
import su.reya.coop.viewmodel.AccountViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun RelayScreen(
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
fun RelayScreen(viewModel: AccountViewModel) {
|
||||
val navigator = LocalNavigator.current
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
val msgRelayList = remember { mutableStateListOf<RelayUrl>() }
|
||||
val relayList = remember { mutableStateMapOf<RelayUrl, RelayMetadata?>() }
|
||||
|
||||
@@ -60,9 +92,30 @@ fun RelayScreen(
|
||||
}
|
||||
}
|
||||
|
||||
var openAddRelayDialog by remember { mutableStateOf(false) }
|
||||
var relayToDelete by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
val accountState by viewModel.state.collectAsStateWithLifecycle()
|
||||
val loadedRelayList = accountState.userRelayList
|
||||
val loadedMsgRelayList = accountState.userMsgRelayList
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
relayList.putAll(viewModel.currentUserRelayList())
|
||||
msgRelayList.addAll(viewModel.currentUserMsgRelayList())
|
||||
viewModel.loadCurrentUserRelayList()
|
||||
viewModel.loadCurrentUserMsgRelayList()
|
||||
}
|
||||
|
||||
LaunchedEffect(loadedRelayList) {
|
||||
if (loadedRelayList.isNotEmpty()) {
|
||||
relayList.clear()
|
||||
relayList.putAll(loadedRelayList)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(loadedMsgRelayList) {
|
||||
if (loadedMsgRelayList.isNotEmpty()) {
|
||||
msgRelayList.clear()
|
||||
msgRelayList.addAll(loadedMsgRelayList)
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
@@ -80,15 +133,39 @@ fun RelayScreen(
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
IconButton(onClick = { navigator.goBack() }) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||
contentDescription = "Back"
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
TooltipBox(
|
||||
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(
|
||||
TooltipAnchorPosition.Above,
|
||||
spacingBetweenTooltipAndAnchor = 8.dp,
|
||||
),
|
||||
tooltip = {
|
||||
PlainTooltip { Text("New Relay") }
|
||||
},
|
||||
state = rememberTooltipState(),
|
||||
) {
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = { openAddRelayDialog = true },
|
||||
expanded = false,
|
||||
icon = {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_plus),
|
||||
contentDescription = "New Relay"
|
||||
)
|
||||
},
|
||||
text = { Text("New Relay") },
|
||||
)
|
||||
}
|
||||
},
|
||||
content = { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -113,7 +190,8 @@ fun RelayScreen(
|
||||
if (msgRelayList.isNotEmpty()) {
|
||||
msgRelayList.forEachIndexed { index, relayUrl ->
|
||||
SegmentedListItem(
|
||||
onClick = { },
|
||||
onClick = { /* No action */ },
|
||||
onLongClick = { relayToDelete = relayUrl.toString() },
|
||||
shapes = ListItemDefaults.segmentedShapes(
|
||||
index = index,
|
||||
count = msgRelayList.size
|
||||
@@ -233,4 +311,216 @@ fun RelayScreen(
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (openAddRelayDialog) {
|
||||
AddRelayDialog(
|
||||
viewModel = viewModel,
|
||||
onDismissRequest = { openAddRelayDialog = false },
|
||||
onMsgRelayAdded = { newRelay ->
|
||||
msgRelayList.add(RelayUrl.parse(newRelay))
|
||||
},
|
||||
onRelayAdded = { newRelay, metadata ->
|
||||
relayList[RelayUrl.parse(newRelay)] = metadata
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (relayToDelete != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { relayToDelete = null },
|
||||
title = { Text("Remove Relay") },
|
||||
text = { Text("Are you sure you want to remove $relayToDelete?") },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
if (msgRelayList.size == 1) {
|
||||
scope.launch {
|
||||
snackbarHostState.showSnackbar("You must have at least one relay")
|
||||
}
|
||||
relayToDelete = null
|
||||
return@TextButton
|
||||
}
|
||||
viewModel.removeMsgRelay(relayToDelete!!)
|
||||
msgRelayList.removeIf { it.toString() == relayToDelete }
|
||||
relayToDelete = null
|
||||
}
|
||||
) {
|
||||
Text("Confirm")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { relayToDelete = null }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun AddRelayDialog(
|
||||
viewModel: AccountViewModel,
|
||||
onDismissRequest: () -> Unit,
|
||||
onMsgRelayAdded: (newRelay: String) -> Unit,
|
||||
onRelayAdded: (newRelay: String, metadata: RelayMetadata?) -> Unit,
|
||||
) {
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
var relayAddress by remember { mutableStateOf("") }
|
||||
var isError by remember { mutableStateOf(false) }
|
||||
val roles = listOf("Messaging", "Inbox", "Outbox")
|
||||
val (selected, onSelected) = remember { mutableStateOf(roles[0]) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = { onDismissRequest() },
|
||||
properties = DialogProperties(
|
||||
usePlatformDefaultWidth = false,
|
||||
dismissOnBackPress = true,
|
||||
decorFitsSystemWindows = false,
|
||||
),
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
title = {
|
||||
Text(
|
||||
text = "New Relay",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { onDismissRequest() }) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_close),
|
||||
contentDescription = "Close"
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = {
|
||||
if (!isError) {
|
||||
when (selected) {
|
||||
"Messaging" -> {
|
||||
viewModel.addMsgRelay(relayAddress)
|
||||
onMsgRelayAdded(relayAddress)
|
||||
}
|
||||
|
||||
"Inbox" -> {
|
||||
viewModel.addInboxRelay(relayAddress)
|
||||
onRelayAdded(relayAddress, RelayMetadata.WRITE)
|
||||
}
|
||||
|
||||
"Outbox" -> {
|
||||
viewModel.addOutboxRelay(relayAddress)
|
||||
onRelayAdded(relayAddress, RelayMetadata.READ)
|
||||
}
|
||||
}
|
||||
onDismissRequest()
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_check),
|
||||
contentDescription = "Add"
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
content = { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(innerPadding)
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = relayAddress,
|
||||
onValueChange = { relayAddress = it },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester),
|
||||
isError = isError,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
isError = relayAddress.isNotEmpty() && !verifyRelayUrl(relayAddress)
|
||||
}
|
||||
),
|
||||
singleLine = true,
|
||||
label = { Text(text = "Relay Address") },
|
||||
placeholder = { Text(text = "wss://relay.example.com") },
|
||||
supportingText = {
|
||||
if (isError) {
|
||||
Text(text = "Invalid format. Must start with wss://")
|
||||
} else {
|
||||
Text(text = "Only add relays you trust.")
|
||||
}
|
||||
},
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Relay Roles",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.selectableGroup(),
|
||||
) {
|
||||
roles.forEach { text ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp)
|
||||
.selectable(
|
||||
onClick = { onSelected(text) },
|
||||
selected = (text == selected),
|
||||
role = Role.RadioButton
|
||||
)
|
||||
.padding(horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
RadioButton(
|
||||
selected = (text == selected),
|
||||
onClick = null
|
||||
)
|
||||
Spacer(modifier = Modifier.size(16.dp))
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun verifyRelayUrl(url: String): Boolean {
|
||||
return try {
|
||||
RelayUrl.parse(url)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
println("Failed to parse relay url: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package su.reya.coop.screens
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
|
||||
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.ic_arrow_back
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
import su.reya.coop.RoomKind
|
||||
import su.reya.coop.Screen
|
||||
import su.reya.coop.viewmodel.ChatViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun RequestListScreen(viewModel: ChatViewModel) {
|
||||
val navigator = LocalNavigator.current
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
|
||||
val listState = rememberLazyListState()
|
||||
val pullToRefreshState = rememberPullToRefreshState()
|
||||
|
||||
var isRefreshing by remember { mutableStateOf(false) }
|
||||
val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle()
|
||||
|
||||
// Get all request rooms
|
||||
val requests = remember(chatRooms) {
|
||||
chatRooms.filter { it.kind == RoomKind.Request }
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
title = {
|
||||
Text(
|
||||
text = "New Requests",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navigator.goBack() }) {
|
||||
Icon(
|
||||
painter = org.jetbrains.compose.resources.painterResource(Res.drawable.ic_arrow_back),
|
||||
contentDescription = "Back"
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier.padding(top = innerPadding.calculateTopPadding()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
||||
) {
|
||||
PullToRefreshBox(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
isRefreshing = isRefreshing,
|
||||
state = pullToRefreshState,
|
||||
onRefresh = {
|
||||
isRefreshing = true
|
||||
viewModel.refreshChatRooms()
|
||||
isRefreshing = false
|
||||
},
|
||||
indicator = {
|
||||
PullToRefreshDefaults.LoadingIndicator(
|
||||
state = pullToRefreshState,
|
||||
isRefreshing = isRefreshing,
|
||||
modifier = Modifier.align(Alignment.TopCenter),
|
||||
)
|
||||
}
|
||||
) {
|
||||
if (requests.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "No requests yet",
|
||||
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||
fontWeight = FontWeight.SemiBold
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = "New chat requests will appear here.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
items(requests.toList(), key = { it.id }) { room ->
|
||||
ChatRoom(
|
||||
room = room,
|
||||
onClick = { navigator.navigate(Screen.Chat(room.id, true)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,16 +5,15 @@ import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -30,34 +29,29 @@ import org.jetbrains.compose.resources.painterResource
|
||||
import org.publicvalue.multiplatform.qrcode.CameraPosition
|
||||
import org.publicvalue.multiplatform.qrcode.CodeType
|
||||
import org.publicvalue.multiplatform.qrcode.ScannerWithPermissions
|
||||
import su.reya.coop.LocalNavController
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalScanResult
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
|
||||
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
|
||||
@Composable
|
||||
fun ScanScreen(
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
val navController = LocalNavController.current
|
||||
fun ScanScreen() {
|
||||
val navigator = LocalNavigator.current
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
|
||||
val onResult: (String) -> Unit = { result ->
|
||||
navController.previousBackStackEntry?.savedStateHandle?.set("qr_result", result)
|
||||
navController.popBackStack()
|
||||
}
|
||||
val qrScanResult = LocalScanResult.current
|
||||
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
CenterAlignedTopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = "Scan QR",
|
||||
text = "Scan a Nostr Address",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
IconButton(onClick = { navigator.goBack() }) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||
contentDescription = "Back"
|
||||
@@ -76,8 +70,8 @@ fun ScanScreen(
|
||||
ScannerWithPermissions(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
onScanned = {
|
||||
println("Scanned: $it");
|
||||
onResult(it)
|
||||
qrScanResult.content = it
|
||||
navigator.goBack()
|
||||
true
|
||||
},
|
||||
types = listOf(CodeType.QR),
|
||||
@@ -102,14 +96,6 @@ fun ScanScreen(
|
||||
.align(Alignment.Center)
|
||||
.border(2.dp, Color.White, RoundedCornerShape(12.dp))
|
||||
)
|
||||
Text(
|
||||
text = "Scan a Nostr address",
|
||||
style = MaterialTheme.typography.titleSmallEmphasized,
|
||||
color = Color.White,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 64.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
package su.reya.coop.screens
|
||||
|
||||
import android.content.Intent
|
||||
import android.provider.Settings as AndroidSettings
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.selection.selectableGroup
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SegmentedListItem
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.ic_arrow_back
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
import su.reya.coop.MediaConfig
|
||||
import su.reya.coop.Theme
|
||||
import su.reya.coop.viewmodel.SettingsViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun SettingsScreen(viewModel: SettingsViewModel) {
|
||||
val navigator = LocalNavigator.current
|
||||
val context = LocalContext.current
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val settings by viewModel.settings.collectAsState()
|
||||
|
||||
var showThemeDialog by remember { mutableStateOf(false) }
|
||||
var showMediaDialog by remember { mutableStateOf(false) }
|
||||
var showBlossomDialog by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = "Settings",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized
|
||||
)
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navigator.goBack() }) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||
contentDescription = "Back"
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp)
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "General",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(horizontal = 8.dp)
|
||||
)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap)
|
||||
) {
|
||||
SegmentedListItem(
|
||||
onClick = { viewModel.update { it.copy(screening = !it.screening) } },
|
||||
shapes = ListItemDefaults.segmentedShapes(index = 0, count = 4),
|
||||
content = { Text("Screening") },
|
||||
supportingContent = { Text("Filter unknown contacts") },
|
||||
trailingContent = {
|
||||
Switch(
|
||||
checked = settings.screening,
|
||||
onCheckedChange = { viewModel.update { s -> s.copy(screening = it) } }
|
||||
)
|
||||
}
|
||||
)
|
||||
SegmentedListItem(
|
||||
onClick = { showMediaDialog = true },
|
||||
shapes = ListItemDefaults.segmentedShapes(index = 1, count = 4),
|
||||
content = { Text("Media Preview") },
|
||||
supportingContent = {
|
||||
Text(
|
||||
when (settings.media) {
|
||||
MediaConfig.Disabled -> "Disabled"
|
||||
MediaConfig.DisabledForMobileData -> "Disabled for Mobile Data"
|
||||
MediaConfig.AlwaysEnabled -> "Always Enabled"
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
SegmentedListItem(
|
||||
onClick = { showBlossomDialog = true },
|
||||
shapes = ListItemDefaults.segmentedShapes(index = 2, count = 4),
|
||||
content = { Text("Blossom Server") },
|
||||
supportingContent = { Text(settings.blossomServer ?: "Default") }
|
||||
)
|
||||
SegmentedListItem(
|
||||
onClick = {
|
||||
val intent = Intent(AndroidSettings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
|
||||
putExtra(AndroidSettings.EXTRA_APP_PACKAGE, context.packageName)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
},
|
||||
shapes = ListItemDefaults.segmentedShapes(index = 3, count = 4),
|
||||
content = { Text("Notifications") },
|
||||
supportingContent = { Text("System notification settings") }
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Appearance",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(horizontal = 8.dp)
|
||||
)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap)
|
||||
) {
|
||||
SegmentedListItem(
|
||||
onClick = { showThemeDialog = true },
|
||||
shapes = ListItemDefaults.segmentedShapes(index = 0, count = 2),
|
||||
content = { Text("Theme") },
|
||||
supportingContent = { Text(settings.theme.name) }
|
||||
)
|
||||
SegmentedListItem(
|
||||
onClick = { viewModel.update { it.copy(dynamicColor = !it.dynamicColor) } },
|
||||
shapes = ListItemDefaults.segmentedShapes(index = 1, count = 2),
|
||||
content = { Text("Dynamic Color") },
|
||||
trailingContent = {
|
||||
Switch(
|
||||
checked = settings.dynamicColor,
|
||||
onCheckedChange = {
|
||||
viewModel.update { s -> s.copy(dynamicColor = it) }
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showThemeDialog) {
|
||||
OptionDialog(
|
||||
title = "Select Theme",
|
||||
options = Theme.entries.map { it.name },
|
||||
selected = settings.theme.name,
|
||||
onSelected = { name ->
|
||||
viewModel.update { it.copy(theme = Theme.valueOf(name)) }
|
||||
showThemeDialog = false
|
||||
},
|
||||
onDismiss = { showThemeDialog = false }
|
||||
)
|
||||
}
|
||||
|
||||
if (showMediaDialog) {
|
||||
OptionDialog(
|
||||
title = "Media Preview",
|
||||
options = listOf("Disabled", "Disabled for Mobile Data", "Always Enabled"),
|
||||
selected = when (settings.media) {
|
||||
MediaConfig.Disabled -> "Disabled"
|
||||
MediaConfig.DisabledForMobileData -> "Disabled for Mobile Data"
|
||||
MediaConfig.AlwaysEnabled -> "Always Enabled"
|
||||
},
|
||||
onSelected = { choice ->
|
||||
val newConfig = when (choice) {
|
||||
"Disabled" -> MediaConfig.Disabled
|
||||
"Disabled for Mobile Data" -> MediaConfig.DisabledForMobileData
|
||||
else -> MediaConfig.AlwaysEnabled
|
||||
}
|
||||
viewModel.update { it.copy(media = newConfig) }
|
||||
showMediaDialog = false
|
||||
},
|
||||
onDismiss = { showMediaDialog = false }
|
||||
)
|
||||
}
|
||||
|
||||
if (showBlossomDialog) {
|
||||
var text by remember { mutableStateOf(settings.blossomServer ?: "") }
|
||||
AlertDialog(
|
||||
onDismissRequest = { showBlossomDialog = false },
|
||||
title = { Text("Blossom Server URL") },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = { Text("https://...") },
|
||||
singleLine = true
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
viewModel.update { it.copy(blossomServer = text.ifBlank { null }) }
|
||||
showBlossomDialog = false
|
||||
}) {
|
||||
Text("Save")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showBlossomDialog = false }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun OptionDialog(
|
||||
title: String,
|
||||
options: List<String>,
|
||||
selected: String,
|
||||
onSelected: (String) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
text = {
|
||||
Column(modifier = Modifier.selectableGroup()) {
|
||||
options.forEach { text ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.selectable(
|
||||
selected = (text == selected),
|
||||
onClick = { onSelected(text) },
|
||||
role = Role.RadioButton
|
||||
)
|
||||
.padding(vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
RadioButton(selected = (text == selected), onClick = null)
|
||||
Spacer(Modifier.size(16.dp))
|
||||
Text(text = text, style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package su.reya.coop.screens
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.shared.ProfileEditor
|
||||
import su.reya.coop.viewmodel.AccountViewModel
|
||||
|
||||
@Composable
|
||||
fun UpdateProfileScreen(viewModel: AccountViewModel) {
|
||||
val navigator = LocalNavigator.current
|
||||
val currentUser by viewModel.currentUserProfile.collectAsStateWithLifecycle()
|
||||
val profile = currentUser?.metadata?.asRecord()
|
||||
val isUpdatingProfile by viewModel.isUpdatingProfile.collectAsStateWithLifecycle()
|
||||
|
||||
ProfileEditor(
|
||||
title = "Update profile",
|
||||
buttonLabel = "Save changes",
|
||||
initialName = profile?.displayName ?: profile?.name ?: "",
|
||||
initialBio = profile?.about ?: "",
|
||||
initialPicture = profile?.picture,
|
||||
isBusy = isUpdatingProfile,
|
||||
onBack = { navigator.goBack() },
|
||||
onConfirm = { name, bio, bytes, type ->
|
||||
viewModel.updateProfile(name, bio, bytes, type)
|
||||
navigator.goBack()
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package su.reya.coop.screens.chat
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialShapes
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.toShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.ic_cancel
|
||||
import coop.composeapp.generated.resources.ic_check_circle
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import rust.nostr.sdk.Timestamp
|
||||
import su.reya.coop.LocalProfileCache
|
||||
import su.reya.coop.Room
|
||||
import su.reya.coop.humanReadable
|
||||
import su.reya.coop.shared.Avatar
|
||||
import su.reya.coop.shared.getExpressiveFontFamily
|
||||
import su.reya.coop.short
|
||||
import su.reya.coop.viewmodel.AccountViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun ScreenerCard(viewModel: AccountViewModel, room: Room) {
|
||||
val pubkey = room.members.firstOrNull() ?: return
|
||||
|
||||
val profileCache = LocalProfileCache.current
|
||||
|
||||
var isContact by remember { mutableStateOf(false) }
|
||||
var mutualContacts by remember { mutableStateOf<Set<PublicKey>>(emptySet()) }
|
||||
var lastActivity by remember { mutableStateOf<Timestamp?>(null) }
|
||||
|
||||
val profileFlow = remember(pubkey) { profileCache.getMetadata(pubkey) }
|
||||
val profile by profileFlow.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(pubkey) {
|
||||
// Check contact
|
||||
viewModel.verifyContact(pubkey) { isContact = it }
|
||||
// Get mutual contacts
|
||||
viewModel.mutualContacts(pubkey) { mutualContacts = it }
|
||||
// Get the last activity
|
||||
viewModel.verifyActivity(pubkey) { lastActivity = it }
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(top = 48.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Avatar(
|
||||
picture = profile?.picture,
|
||||
description = "Profile picture",
|
||||
modifier = Modifier.size(120.dp),
|
||||
shape = MaterialShapes.Cookie12Sided.toShape(),
|
||||
)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = profile?.name ?: "No name",
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||
fontFamily = getExpressiveFontFamily()
|
||||
),
|
||||
)
|
||||
Text(
|
||||
text = pubkey.short(),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(
|
||||
if (isContact) Res.drawable.ic_check_circle else Res.drawable.ic_cancel
|
||||
),
|
||||
contentDescription = "Warning",
|
||||
tint = if (isContact) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error
|
||||
)
|
||||
Text(
|
||||
text = if (isContact) "Contact" else "Not a contact",
|
||||
style = MaterialTheme.typography.labelMediumEmphasized
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(
|
||||
if (mutualContacts.isNotEmpty()) Res.drawable.ic_check_circle else Res.drawable.ic_cancel
|
||||
),
|
||||
contentDescription = "Warning",
|
||||
tint = if (mutualContacts.isNotEmpty()) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error
|
||||
)
|
||||
Text(
|
||||
text = if (mutualContacts.isEmpty()) "No contacts in common" else "${mutualContacts.size} contacts in common",
|
||||
style = MaterialTheme.typography.labelMediumEmphasized
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_check_circle),
|
||||
contentDescription = "Warning",
|
||||
tint = if (lastActivity != null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline
|
||||
)
|
||||
Text(
|
||||
text = if (lastActivity == null) "Don't have any public activities" else "Last activity at ${lastActivity?.humanReadable()}",
|
||||
style = MaterialTheme.typography.labelMediumEmphasized
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DateSeparator(date: String) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = date,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package su.reya.coop.screens.chat
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.ic_add_circle
|
||||
import coop.composeapp.generated.resources.ic_audio
|
||||
import coop.composeapp.generated.resources.ic_send
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
|
||||
@Composable
|
||||
fun ChatInput(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
onSend: () -> Unit,
|
||||
onUpload: () -> Unit,
|
||||
onMicClick: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.Bottom
|
||||
) {
|
||||
TextField(
|
||||
modifier = Modifier.weight(1f),
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent
|
||||
),
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
placeholder = { Text("Message") },
|
||||
maxLines = 5,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.Sentences,
|
||||
imeAction = ImeAction.Default
|
||||
),
|
||||
leadingIcon = {
|
||||
IconButton(onClick = onUpload) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_add_circle),
|
||||
contentDescription = "Upload",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
AnimatedContent(
|
||||
targetState = value.isNotEmpty(),
|
||||
transitionSpec = { (scaleIn() + fadeIn()) togetherWith (scaleOut() + fadeOut()) },
|
||||
label = "send_mic_transition"
|
||||
) { isNotEmpty ->
|
||||
if (isNotEmpty) {
|
||||
IconButton(
|
||||
onClick = onSend,
|
||||
modifier = Modifier.size(56.dp),
|
||||
colors = IconButtonDefaults.iconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_send),
|
||||
contentDescription = "Send"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
FilledTonalIconButton(
|
||||
onClick = onMicClick,
|
||||
modifier = Modifier.size(56.dp),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_audio),
|
||||
contentDescription = "Speech to Text"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package su.reya.coop.screens.chat
|
||||
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.LayoutCoordinates
|
||||
import androidx.compose.ui.layout.boundsInWindow
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.LinkAnnotation
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextLinkStyles
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import rust.nostr.sdk.EventId
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import rust.nostr.sdk.UnsignedEvent
|
||||
import su.reya.coop.LocalConnectivity
|
||||
import su.reya.coop.LocalSettings
|
||||
import su.reya.coop.MediaConfig
|
||||
import su.reya.coop.URL_REGEX
|
||||
import su.reya.coop.formatAsTime
|
||||
import su.reya.coop.isImageUrl
|
||||
import su.reya.coop.removeImageUrls
|
||||
|
||||
@Immutable
|
||||
data class MessageModel(
|
||||
val id: EventId,
|
||||
val author: PublicKey,
|
||||
val annotatedContent: AnnotatedString,
|
||||
val images: List<String>,
|
||||
val timestamp: String,
|
||||
val isMine: Boolean,
|
||||
val replyEventIds: List<EventId>
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun rememberMessageModel(event: UnsignedEvent, currentUser: PublicKey? = null): MessageModel {
|
||||
val settings = LocalSettings.current
|
||||
val isMobileData = LocalConnectivity.current
|
||||
|
||||
return remember(event, currentUser, settings, isMobileData) {
|
||||
val id = event.ensureId().id()!!
|
||||
val isMine = currentUser == event.author()
|
||||
val content = event.content()
|
||||
val replyEventIds = event.tags().eventIds()
|
||||
|
||||
val showMedia = when (settings.media) {
|
||||
MediaConfig.AlwaysEnabled -> true
|
||||
MediaConfig.Disabled -> false
|
||||
MediaConfig.DisabledForMobileData -> !isMobileData
|
||||
}
|
||||
|
||||
val images = if (showMedia) {
|
||||
URL_REGEX.findAll(content).map { it.value }.filter { it.isImageUrl() }.toList()
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
val cleanedContent = if (showMedia) content.removeImageUrls() else content
|
||||
|
||||
val annotatedString = buildAnnotatedString {
|
||||
var lastIndex = 0
|
||||
URL_REGEX.findAll(cleanedContent).forEach { matchResult ->
|
||||
append(cleanedContent.substring(lastIndex, matchResult.range.first))
|
||||
val url = matchResult.value
|
||||
pushLink(
|
||||
LinkAnnotation.Url(
|
||||
url = url,
|
||||
styles = TextLinkStyles(
|
||||
style = SpanStyle(
|
||||
textDecoration = TextDecoration.Underline,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
append(url)
|
||||
pop()
|
||||
lastIndex = matchResult.range.last + 1
|
||||
}
|
||||
append(cleanedContent.substring(lastIndex))
|
||||
}
|
||||
|
||||
MessageModel(
|
||||
id = id,
|
||||
author = event.author(),
|
||||
annotatedContent = annotatedString,
|
||||
images = images,
|
||||
timestamp = event.createdAt().formatAsTime(),
|
||||
isMine = isMine,
|
||||
replyEventIds = replyEventIds
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatMessage(
|
||||
model: MessageModel,
|
||||
modifier: Modifier = Modifier,
|
||||
onLongClick: (Rect) -> Unit = {}
|
||||
) {
|
||||
var isMessageClicked by remember { mutableStateOf(false) }
|
||||
var layoutCoordinates by remember { mutableStateOf<LayoutCoordinates?>(null) }
|
||||
|
||||
val bubbleShape = if (model.isMine) {
|
||||
RoundedCornerShape(topStart = 20.dp, topEnd = 4.dp, bottomStart = 20.dp, bottomEnd = 20.dp)
|
||||
} else {
|
||||
RoundedCornerShape(topStart = 4.dp, topEnd = 20.dp, bottomStart = 20.dp, bottomEnd = 20.dp)
|
||||
}
|
||||
|
||||
val containerColor =
|
||||
if (!model.isMine) MaterialTheme.colorScheme.surfaceContainer else MaterialTheme.colorScheme.primaryContainer
|
||||
|
||||
val contentColor =
|
||||
if (!model.isMine) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onPrimaryContainer
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.onGloballyPositioned { layoutCoordinates = it }
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
contentAlignment = if (model.isMine) Alignment.CenterEnd else Alignment.CenterStart
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.combinedClickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = { isMessageClicked = !isMessageClicked },
|
||||
onLongClick = {
|
||||
layoutCoordinates?.let { coords ->
|
||||
onLongClick(coords.boundsInWindow())
|
||||
}
|
||||
}
|
||||
),
|
||||
horizontalAlignment = if (model.isMine) Alignment.End else Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
if (model.annotatedContent.isNotBlank()) {
|
||||
Surface(
|
||||
modifier = Modifier.widthIn(max = 280.dp),
|
||||
color = containerColor,
|
||||
contentColor = contentColor,
|
||||
shape = bubbleShape,
|
||||
) {
|
||||
Text(
|
||||
text = model.annotatedContent,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
}
|
||||
}
|
||||
model.images.forEach { imageUrl ->
|
||||
Surface(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier.widthIn(max = 280.dp)
|
||||
) {
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = "Image from chat",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp)),
|
||||
contentScale = ContentScale.FillWidth
|
||||
)
|
||||
}
|
||||
}
|
||||
if (isMessageClicked) {
|
||||
Text(
|
||||
text = model.timestamp,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
modifier = Modifier.align(
|
||||
if (model.isMine) Alignment.End else Alignment.Start
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,656 @@
|
||||
package su.reya.coop.screens.chat
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ClipData
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.speech.RecognizerIntent
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.union
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Badge
|
||||
import androidx.compose.material3.BadgedBox
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.DropdownMenuGroup
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LoadingIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.MenuDefaults
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.ClipEntry
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalWindowInfo
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.ic_arrow_back
|
||||
import coop.composeapp.generated.resources.ic_copy
|
||||
import coop.composeapp.generated.resources.ic_reply
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import rust.nostr.sdk.EventId
|
||||
import rust.nostr.sdk.UnsignedEvent
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalProfileCache
|
||||
import su.reya.coop.LocalSettings
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
import su.reya.coop.Room
|
||||
import su.reya.coop.RoomUiState
|
||||
import su.reya.coop.Screen
|
||||
import su.reya.coop.formatAsGroup
|
||||
import su.reya.coop.shared.Avatar
|
||||
import su.reya.coop.uiStateFlow
|
||||
import su.reya.coop.viewmodel.AccountViewModel
|
||||
import su.reya.coop.viewmodel.ChatScreenViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun ChatScreen(
|
||||
viewModel: ChatScreenViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
coroutineDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val clipboardManager = LocalClipboard.current
|
||||
val navigator = LocalNavigator.current
|
||||
val profileCache = LocalProfileCache.current
|
||||
val settings = LocalSettings.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
val id = viewModel.id
|
||||
val currentUser by viewModel.currentUser.collectAsStateWithLifecycle()
|
||||
val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle()
|
||||
val room by remember(id) { derivedStateOf { chatRooms.firstOrNull { it.id == id } } }
|
||||
|
||||
// Show empty screen
|
||||
if (room == null) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Something went wrong.",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val loading = viewModel.loading
|
||||
val newOtherMessages = viewModel.newOtherMessages
|
||||
val requireScreening = viewModel.requireScreening
|
||||
val messages = viewModel.messages
|
||||
|
||||
val groupedMessages =
|
||||
remember { derivedStateOf { messages.groupBy { it.createdAt().formatAsGroup() } } }
|
||||
|
||||
val roomState by remember(id, currentUser?.publicKey) {
|
||||
(room as Room).uiStateFlow(profileCache, currentUser?.publicKey)
|
||||
}.collectAsStateWithLifecycle(RoomUiState())
|
||||
|
||||
var text by remember { mutableStateOf("") }
|
||||
var selectedMessage by remember { mutableStateOf<Pair<MessageModel, Rect>?>(null) }
|
||||
var replyingTo by remember { mutableStateOf<MessageModel?>(null) }
|
||||
|
||||
val blurAmount by animateDpAsState(
|
||||
targetValue = if (selectedMessage != null) 8.dp else 0.dp,
|
||||
label = "blurAnimation"
|
||||
)
|
||||
|
||||
val goToMessage = { eventId: EventId? ->
|
||||
if (eventId != null) {
|
||||
scope.launch {
|
||||
var targetIndex = -1
|
||||
var currentIndex = 0
|
||||
|
||||
for (group in groupedMessages.value) {
|
||||
val msgInGroup = group.value
|
||||
val idx = msgInGroup.indexOfFirst { it.id() == eventId }
|
||||
if (idx != -1) {
|
||||
targetIndex = currentIndex + idx
|
||||
break
|
||||
}
|
||||
currentIndex += msgInGroup.size + 1
|
||||
}
|
||||
|
||||
if (targetIndex != -1) {
|
||||
listState.animateScrollToItem(targetIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val sendFile = { uri: Uri ->
|
||||
scope.launch {
|
||||
// Read file on IO dispatcher
|
||||
val file = withContext(coroutineDispatcher) {
|
||||
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||
}
|
||||
|
||||
// Parse the file content type
|
||||
val type = context.contentResolver.getType(uri)
|
||||
|
||||
// Send message (handles errors internally via ViewModel)
|
||||
viewModel.sendFileMessage(file, type)
|
||||
}
|
||||
}
|
||||
|
||||
val fileLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||
uri?.let { sendFile(it) }
|
||||
}
|
||||
|
||||
val sttLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode == Activity.RESULT_OK) {
|
||||
val data = result.data
|
||||
val results = data?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
|
||||
if (!results.isNullOrEmpty()) text = results[0]
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(messages.size) {
|
||||
if (messages.isNotEmpty()) {
|
||||
listState.animateScrollToItem(0)
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Scaffold(
|
||||
modifier = Modifier.blur(blurAmount),
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime),
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.clickable {
|
||||
room?.members?.firstOrNull()?.let { pubkey ->
|
||||
navigator.navigate(Screen.Profile(pubkey.toBech32()))
|
||||
}
|
||||
}
|
||||
) {
|
||||
if (loading) {
|
||||
LoadingIndicator(modifier = Modifier.size(32.dp))
|
||||
} else {
|
||||
Avatar(
|
||||
picture = roomState.picture,
|
||||
description = roomState.name,
|
||||
size = 32.dp,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Text(
|
||||
text = roomState.name,
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
)
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
BadgedBox(
|
||||
badge = {
|
||||
if (newOtherMessages > 0) {
|
||||
Badge {
|
||||
Text(newOtherMessages.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
IconButton(onClick = { navigator.goBack() }) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||
contentDescription = "Back"
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
)
|
||||
)
|
||||
},
|
||||
content = { innerPadding ->
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = innerPadding.calculateTopPadding()),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(bottom = innerPadding.calculateBottomPadding())
|
||||
) {
|
||||
if (requireScreening && settings.screening) {
|
||||
room?.let { ScreenerCard(accountViewModel, it) }
|
||||
}
|
||||
|
||||
when (messages.isNotEmpty()) {
|
||||
true -> {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
state = listState,
|
||||
reverseLayout = true,
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
) {
|
||||
groupedMessages.value.forEach { (dateHeader, messagesInGroup) ->
|
||||
items(
|
||||
items = messagesInGroup,
|
||||
key = { it.ensureId().id()?.toHex()!! }
|
||||
) { event ->
|
||||
val model =
|
||||
rememberMessageModel(event, currentUser?.publicKey)
|
||||
|
||||
val replyPreview =
|
||||
remember(model.replyEventIds, messages.size) {
|
||||
model.replyEventIds.firstOrNull()
|
||||
?.let { replyId ->
|
||||
messages.find { it.id() == replyId }
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.animateItem(),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
replyPreview?.let { previewEvent ->
|
||||
ReplyPreview(
|
||||
event = previewEvent,
|
||||
isMine = model.isMine,
|
||||
onClick = { goToMessage(previewEvent.id()) }
|
||||
)
|
||||
}
|
||||
ChatMessage(
|
||||
model = model,
|
||||
modifier = Modifier.graphicsLayer {
|
||||
alpha =
|
||||
if (selectedMessage?.first?.id == model.id) 0f else 1f
|
||||
},
|
||||
onLongClick = { rect ->
|
||||
selectedMessage = model to rect
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
item {
|
||||
DateSeparator(dateHeader)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "No messages yet",
|
||||
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||
fontWeight = FontWeight.SemiBold
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = "Your conversations will appear here.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when (requireScreening && settings.screening) {
|
||||
true -> {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Button(
|
||||
onClick = { navigator.goBack() },
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.size(ButtonDefaults.MediumContainerHeight)
|
||||
) {
|
||||
Text(
|
||||
text = "Reject",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
FilledTonalButton(
|
||||
onClick = { viewModel.requireScreening = false },
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.size(ButtonDefaults.MediumContainerHeight)
|
||||
) {
|
||||
Text(
|
||||
text = "Accept",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
AnimatedVisibility(visible = replyingTo != null) {
|
||||
replyingTo?.let {
|
||||
ReplyBox(it) { replyingTo = null }
|
||||
}
|
||||
}
|
||||
ChatInput(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
onSend = {
|
||||
viewModel.sendMessage(text, replyingTo?.id)
|
||||
text = ""
|
||||
replyingTo = null
|
||||
},
|
||||
onUpload = {
|
||||
fileLauncher.launch("image/*")
|
||||
},
|
||||
onMicClick = {
|
||||
val intent =
|
||||
Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
|
||||
putExtra(
|
||||
RecognizerIntent.EXTRA_LANGUAGE_MODEL,
|
||||
RecognizerIntent.LANGUAGE_MODEL_FREE_FORM
|
||||
)
|
||||
putExtra(
|
||||
RecognizerIntent.EXTRA_PROMPT,
|
||||
"Speak now..."
|
||||
)
|
||||
}
|
||||
try {
|
||||
sttLauncher.launch(intent)
|
||||
} catch (e: Exception) {
|
||||
scope.launch {
|
||||
snackbarHostState.showSnackbar("Speech recognition not available")
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
AnimatedVisibility(
|
||||
visible = selectedMessage != null,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
) {
|
||||
val (model, bounds) = selectedMessage ?: return@AnimatedVisibility
|
||||
|
||||
val density = LocalDensity.current
|
||||
val windowInfo = LocalWindowInfo.current
|
||||
val windowHeight = windowInfo.containerSize.height
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
var menuHeight by remember { mutableFloatStateOf(0f) }
|
||||
val spacing = with(density) { 12.dp.toPx() }
|
||||
val showAbove =
|
||||
(windowHeight - bounds.bottom) < (menuHeight + spacing) && bounds.top > (menuHeight + spacing)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.4f))
|
||||
.clickable { selectedMessage = null }
|
||||
.verticalScroll(scrollState),
|
||||
) {
|
||||
val totalExtraHeight = if (menuHeight > 0) menuHeight + spacing else 300f
|
||||
val contentBottom = with(density) { (bounds.bottom + totalExtraHeight).toDp() }
|
||||
|
||||
Spacer(modifier = Modifier.height(contentBottom + 200.dp))
|
||||
|
||||
ChatMessage(
|
||||
model = model,
|
||||
modifier = Modifier
|
||||
.offset { IntOffset(0, bounds.top.toInt()) }
|
||||
.padding(horizontal = 16.dp)
|
||||
)
|
||||
|
||||
val menuOffset = if (showAbove) {
|
||||
bounds.top - menuHeight - spacing
|
||||
} else {
|
||||
bounds.bottom + spacing
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.offset { IntOffset(0, menuOffset.toInt().coerceAtLeast(0)) }
|
||||
.onGloballyPositioned { menuHeight = it.size.height.toFloat() }
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
contentAlignment = if (model.isMine) Alignment.CenterEnd else Alignment.CenterStart
|
||||
) {
|
||||
ContextMenu { action ->
|
||||
when (action) {
|
||||
"Copy" -> {
|
||||
scope.launch {
|
||||
val content = model.annotatedContent
|
||||
val data = ClipData.newPlainText(content, content)
|
||||
clipboardManager.setClipEntry(ClipEntry(data))
|
||||
}
|
||||
}
|
||||
|
||||
"Reply" -> {
|
||||
replyingTo = model
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
selectedMessage = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReplyBox(model: MessageModel, onDismiss: () -> Unit) {
|
||||
val profileCache = LocalProfileCache.current
|
||||
val profileFlow = remember(model) { profileCache.getMetadata(model.author) }
|
||||
val profile by profileFlow.collectAsStateWithLifecycle()
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onDismiss() },
|
||||
color = MaterialTheme.colorScheme.tertiaryContainer,
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Replying to ${profile?.name}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer.copy(
|
||||
alpha = 0.6f
|
||||
),
|
||||
)
|
||||
Text(
|
||||
text = model.annotatedContent.toString().ifBlank {
|
||||
if (model.images.isNotEmpty()) "[Image]" else ""
|
||||
},
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReplyPreview(
|
||||
event: UnsignedEvent,
|
||||
isMine: Boolean = false,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val profileCache = LocalProfileCache.current
|
||||
val profileFlow = remember(event) { profileCache.getMetadata(event.author()) }
|
||||
val profile by profileFlow.collectAsStateWithLifecycle()
|
||||
|
||||
val bubbleShape = if (isMine) {
|
||||
RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp, bottomStart = 20.dp, bottomEnd = 4.dp)
|
||||
} else {
|
||||
RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp, bottomStart = 4.dp, bottomEnd = 20.dp)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = if (isMine) Alignment.CenterEnd else Alignment.CenterStart
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 280.dp)
|
||||
.clickable(onClick = onClick),
|
||||
color = MaterialTheme.colorScheme.tertiaryContainer,
|
||||
shape = bubbleShape,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = profile?.name ?: "Unknown",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
)
|
||||
Text(
|
||||
text = event.content(),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
private fun ContextMenu(onAction: (String) -> Unit) {
|
||||
val menuItems = listOf(
|
||||
"Copy" to Res.drawable.ic_copy,
|
||||
"Reply" to Res.drawable.ic_reply
|
||||
)
|
||||
|
||||
DropdownMenuGroup(
|
||||
shapes = MenuDefaults.groupShape(1, 1),
|
||||
containerColor = MenuDefaults.groupVibrantContainerColor,
|
||||
modifier = Modifier.width(220.dp)
|
||||
) {
|
||||
val itemCount = menuItems.size
|
||||
|
||||
menuItems.forEachIndexed { index, (label, icon) ->
|
||||
DropdownMenuItem(
|
||||
shapes = MenuDefaults.itemShape(index, itemCount),
|
||||
colors = MenuDefaults.selectableItemVibrantColors(),
|
||||
text = { Text(label) },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
painter = painterResource(icon),
|
||||
contentDescription = label,
|
||||
)
|
||||
},
|
||||
checked = false,
|
||||
onCheckedChange = { _ -> onAction(label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package su.reya.coop.shared
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -13,6 +14,9 @@ import coil3.compose.AsyncImage
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.avatar
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import su.reya.coop.LocalConnectivity
|
||||
import su.reya.coop.LocalSettings
|
||||
import su.reya.coop.MediaConfig
|
||||
|
||||
@Composable
|
||||
fun Avatar(
|
||||
@@ -22,17 +26,36 @@ fun Avatar(
|
||||
size: Dp = 48.dp,
|
||||
shape: Shape = CircleShape
|
||||
) {
|
||||
val settings = LocalSettings.current
|
||||
val isMobileData = LocalConnectivity.current
|
||||
val placeholder = painterResource(Res.drawable.avatar)
|
||||
|
||||
AsyncImage(
|
||||
model = picture,
|
||||
contentDescription = description,
|
||||
modifier = modifier
|
||||
.size(size)
|
||||
.clip(shape),
|
||||
contentScale = ContentScale.Crop,
|
||||
fallback = placeholder,
|
||||
error = placeholder,
|
||||
placeholder = placeholder
|
||||
)
|
||||
val showMedia = when (settings.media) {
|
||||
MediaConfig.AlwaysEnabled -> true
|
||||
MediaConfig.Disabled -> false
|
||||
MediaConfig.DisabledForMobileData -> !isMobileData
|
||||
}
|
||||
|
||||
if (showMedia) {
|
||||
AsyncImage(
|
||||
model = picture,
|
||||
contentDescription = description,
|
||||
modifier = modifier
|
||||
.size(size)
|
||||
.clip(shape),
|
||||
contentScale = ContentScale.Crop,
|
||||
fallback = placeholder,
|
||||
error = placeholder,
|
||||
placeholder = placeholder
|
||||
)
|
||||
} else {
|
||||
Image(
|
||||
painter = placeholder,
|
||||
contentDescription = description,
|
||||
modifier = modifier
|
||||
.size(size)
|
||||
.clip(shape),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package su.reya.coop.shared
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import coop.composeapp.generated.resources.PaytoneOne_Regular
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import org.jetbrains.compose.resources.Font
|
||||
|
||||
@Composable
|
||||
fun getExpressiveFontFamily() = FontFamily(
|
||||
Font(Res.font.PaytoneOne_Regular, FontWeight.Normal)
|
||||
)
|
||||
@@ -0,0 +1,301 @@
|
||||
package su.reya.coop.shared
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LoadingIndicator
|
||||
import androidx.compose.material3.MaterialShapes
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.toShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.ic_arrow_back
|
||||
import coop.composeapp.generated.resources.ic_plus
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun ProfileEditor(
|
||||
title: String,
|
||||
buttonLabel: String,
|
||||
initialName: String = "",
|
||||
initialBio: String = "",
|
||||
initialPicture: Any? = null, // Accepts Uri (picked) or String (current URL)
|
||||
isBusy: Boolean = false,
|
||||
onBack: () -> Unit,
|
||||
onConfirm: (name: String, bio: String, pictureBytes: ByteArray?, contentType: String?) -> Unit,
|
||||
ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var name by remember(initialName) { mutableStateOf(initialName) }
|
||||
var bio by remember(initialBio) { mutableStateOf(initialBio) }
|
||||
var picture by remember(initialPicture) { mutableStateOf(initialPicture) }
|
||||
|
||||
val hasPicture = remember(picture) {
|
||||
when (picture) {
|
||||
null -> false
|
||||
is String -> (picture as CharSequence).isNotBlank()
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
|
||||
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||
picture = uri
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(title, style = MaterialTheme.typography.titleMediumEmphasized) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
painterResource(Res.drawable.ic_arrow_back),
|
||||
contentDescription = "Back"
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
)
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = innerPadding.calculateTopPadding())
|
||||
.imePadding(),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(120.dp)
|
||||
.clip(MaterialShapes.Pentagon.toShape())
|
||||
.clickable { launcher.launch("image/*") },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (hasPicture) {
|
||||
AsyncImage(
|
||||
model = picture,
|
||||
contentDescription = "Profile picture",
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
} else {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.tertiaryContainer,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_plus),
|
||||
contentDescription = "Pick avatar",
|
||||
modifier = Modifier.size(48.dp),
|
||||
tint = MaterialTheme.colorScheme.onTertiaryFixed
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f, fill = true),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "What others should call you?",
|
||||
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
)
|
||||
BasicTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
enabled = !isBusy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
imeAction = ImeAction.Done,
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
focusManager.clearFocus()
|
||||
}
|
||||
),
|
||||
textStyle = MaterialTheme.typography.headlineLargeEmphasized.copy(
|
||||
color = MaterialTheme.colorScheme.tertiaryFixedDim,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.tertiaryContainer),
|
||||
decorationBox = { innerTextField ->
|
||||
Box(contentAlignment = Alignment.CenterStart) {
|
||||
if (name.isEmpty()) {
|
||||
Text(
|
||||
"Alice",
|
||||
style = MaterialTheme.typography.headlineLargeEmphasized.copy(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
|
||||
alpha = 0.5f
|
||||
)
|
||||
)
|
||||
}
|
||||
innerTextField()
|
||||
}
|
||||
}
|
||||
)
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Text(
|
||||
text = "Your bio (optional)",
|
||||
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
)
|
||||
BasicTextField(
|
||||
value = bio,
|
||||
onValueChange = { bio = it },
|
||||
enabled = !isBusy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxLines = 3,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
imeAction = ImeAction.Done,
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
focusManager.clearFocus()
|
||||
}
|
||||
),
|
||||
textStyle = MaterialTheme.typography.bodyLarge.copy(
|
||||
color = MaterialTheme.colorScheme.primaryFixed,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.secondary),
|
||||
decorationBox = { innerTextField ->
|
||||
Box(contentAlignment = Alignment.CenterStart) {
|
||||
if (bio.isEmpty()) {
|
||||
Text(
|
||||
"I love cat",
|
||||
style = MaterialTheme.typography.headlineLargeEmphasized.copy(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
|
||||
alpha = 0.5f
|
||||
)
|
||||
)
|
||||
}
|
||||
innerTextField()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.size(16.dp))
|
||||
Button(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.size(ButtonDefaults.MediumContainerHeight),
|
||||
onClick = {
|
||||
scope.launch {
|
||||
try {
|
||||
val bytes = withContext(ioDispatcher) {
|
||||
(picture as? Uri)?.let {
|
||||
context.contentResolver.openInputStream(it)?.readBytes()
|
||||
}
|
||||
}
|
||||
val type =
|
||||
(picture as? Uri)?.let { context.contentResolver.getType(it) }
|
||||
onConfirm(name, bio, bytes, type)
|
||||
} catch (e: Exception) {
|
||||
snackbarHostState.showSnackbar(e.message ?: "Error")
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = name.isNotBlank() && !isBusy
|
||||
) {
|
||||
if (isBusy) {
|
||||
LoadingIndicator()
|
||||
} else {
|
||||
Text(
|
||||
text = buttonLabel,
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package su.reya.coop.shared
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import su.reya.coop.NostrViewModel
|
||||
import su.reya.coop.Room
|
||||
import su.reya.coop.short
|
||||
|
||||
fun Room.displayNameFlow(viewModel: NostrViewModel): Flow<String> {
|
||||
if (!subject.isNullOrBlank()) return flowOf<String>(subject!!)
|
||||
|
||||
val memberFlows = members.map { viewModel.getMetadata(it) }
|
||||
|
||||
return combine(memberFlows) { metadataArray ->
|
||||
if (isGroup()) {
|
||||
val profiles = metadataArray.map { it?.asRecord() }
|
||||
val names = profiles.take(2).mapNotNull { it?.name ?: it?.displayName }
|
||||
var combined = names.joinToString(", ")
|
||||
if (profiles.size > 2) combined += ", +${profiles.size - 2}"
|
||||
combined.ifBlank { "Unknown group" }
|
||||
} else {
|
||||
val profile = metadataArray.firstOrNull()?.asRecord()
|
||||
profile?.name ?: profile?.displayName ?: members.firstOrNull()?.short() ?: "Unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Room.pictureFlow(viewModel: NostrViewModel): Flow<String?> {
|
||||
val firstMember = members.firstOrNull() ?: return kotlinx.coroutines.flow.flowOf(null)
|
||||
return viewModel.getMetadata(firstMember).map { it?.asRecord()?.picture }
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package su.reya.coop.coop.storage
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.first
|
||||
import su.reya.coop.storage.SecretStorage
|
||||
|
||||
private val Context.dataStore by preferencesDataStore("secret_store")
|
||||
|
||||
class SecretStore(private val context: Context) : SecretStorage {
|
||||
private val crypto = SecretCrypto()
|
||||
|
||||
override suspend fun set(key: String, value: String) {
|
||||
val entry = crypto.encrypt(value)
|
||||
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[stringPreferencesKey("${key}_encrypted")] = entry.encrypted
|
||||
prefs[stringPreferencesKey("${key}_iv")] = entry.iv
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun get(key: String): String? {
|
||||
val prefs = context.dataStore.data.first()
|
||||
val encrypted = prefs[stringPreferencesKey("${key}_encrypted")] ?: return null
|
||||
val iv = prefs[stringPreferencesKey("${key}_iv")] ?: return null
|
||||
|
||||
return crypto.decrypt(SecretEntry(encrypted, iv))
|
||||
}
|
||||
|
||||
override suspend fun clear(key: String) {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs.remove(stringPreferencesKey("${key}_encrypted"))
|
||||
prefs.remove(stringPreferencesKey("${key}_iv"))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun has(key: String): Boolean {
|
||||
val prefs = context.dataStore.data.first()
|
||||
return prefs[stringPreferencesKey("${key}_encrypted")] != null
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 551 B |
|
After Width: | Height: | Size: 391 B |
|
After Width: | Height: | Size: 727 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,22 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="200dp"
|
||||
android:height="200dp"
|
||||
android:viewportWidth="120"
|
||||
android:viewportHeight="120">
|
||||
|
||||
<group
|
||||
android:translateX="20"
|
||||
android:translateY="20">
|
||||
<path
|
||||
android:pathData="M37.54,74C52.75,74 65.08,61.581 65.08,46.262C65.08,44.87 63.959,43.74 62.576,43.74H12.504C11.12,43.74 10,44.87 10,46.262C10,61.581 22.33,74 37.54,74Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillType="evenOdd"/>
|
||||
<path
|
||||
android:pathData="M49.707,14.321L49.493,14.263L49.279,14.207L49.062,14.153L48.843,14.099L48.627,14.049L48.411,14L48.194,13.952L47.976,13.907L47.748,13.861L47.518,13.817L47.287,13.775L47.055,13.735L46.94,13.715L46.828,13.697L46.604,13.662L46.381,13.629L46.157,13.598L45.927,13.568L45.696,13.54L45.467,13.514L45.236,13.49L45.005,13.467L44.774,13.447L44.543,13.428L44.31,13.412L44.08,13.398L43.851,13.385L43.623,13.375L43.392,13.366L43.159,13.359L42.926,13.354L42.694,13.351L42.46,13.35H42.184L41.903,13.356L41.623,13.363L41.343,13.373L41.063,13.385L40.784,13.401L40.504,13.419L40.224,13.44L39.932,13.466L39.64,13.494L39.357,13.524L39.074,13.558L38.781,13.596L38.489,13.637L38.209,13.679L37.929,13.724L37.649,13.772L37.369,13.823L37.103,13.875L36.836,13.929L36.57,13.986L36.418,14.02L36.306,14.045L36.026,14.111L35.746,14.18L35.466,14.252L35.187,14.328L34.917,14.403L34.648,14.482L34.512,14.523L34.376,14.564L34.109,14.649L33.847,14.734L33.587,14.821C33.587,14.821 32.45,13.249 32.536,12.136C32.644,10.731 33.588,9.592 34.837,9.163C34.764,8.837 34.687,8.51 34.713,8.16C34.859,6.274 36.501,4.864 38.377,5.01C39.485,5.096 40.376,5.734 40.932,6.605C41.616,5.83 42.598,5.339 43.705,5.425C45.449,5.561 46.733,7.01 46.794,8.726C47.033,8.691 47.264,8.617 47.516,8.637C49.394,8.783 50.796,10.429 50.65,12.315C50.58,13.232 49.707,14.321 49.707,14.321Z"
|
||||
android:fillColor="#000000"/>
|
||||
<path
|
||||
android:pathData="M14.92,41.088C14.92,25.769 27.25,13.35 42.46,13.35C57.669,13.35 70,25.769 70,41.088C70,42.481 68.879,43.61 67.496,43.61H17.424C16.04,43.61 14.92,42.481 14.92,41.088ZM54.627,26.453C54.476,26.411 54.325,26.389 54.172,26.389C54.018,26.389 53.867,26.411 53.717,26.453C53.566,26.496 53.42,26.561 53.279,26.645C53.138,26.73 53.003,26.833 52.876,26.956C52.748,27.079 52.63,27.218 52.522,27.375C52.413,27.531 52.316,27.701 52.232,27.885C52.147,28.068 52.075,28.262 52.016,28.467C51.957,28.671 51.913,28.882 51.883,29.098C51.854,29.315 51.839,29.533 51.839,29.755C51.839,29.976 51.854,30.195 51.883,30.411C51.913,30.628 51.957,30.838 52.016,31.043C52.075,31.247 52.147,31.441 52.232,31.625C52.316,31.808 52.413,31.979 52.522,32.135C52.63,32.291 52.748,32.43 52.876,32.553C53.003,32.676 53.138,32.78 53.279,32.864C53.42,32.949 53.566,33.013 53.717,33.056C53.867,33.1 54.018,33.121 54.172,33.121C54.325,33.121 54.476,33.1 54.627,33.056C54.777,33.013 54.923,32.949 55.064,32.864C55.206,32.78 55.341,32.676 55.468,32.553C55.595,32.43 55.714,32.291 55.822,32.135C55.93,31.979 56.027,31.808 56.112,31.625C56.197,31.441 56.268,31.247 56.327,31.043C56.386,30.838 56.431,30.628 56.46,30.411C56.49,30.195 56.505,29.976 56.505,29.755C56.505,29.533 56.49,29.315 56.46,29.098C56.431,28.882 56.386,28.671 56.327,28.467C56.268,28.262 56.197,28.068 56.112,27.885C56.027,27.701 55.93,27.531 55.822,27.375C55.714,27.218 55.595,27.079 55.468,26.956C55.341,26.833 55.206,26.73 55.064,26.645C54.923,26.561 54.777,26.496 54.627,26.453Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillType="evenOdd"/>
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="secondaryContainer">#F8FF37</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<style name="Theme.App.Starting" parent="Theme.SplashScreen">
|
||||
<item name="windowSplashScreenBackground">@color/secondaryContainer</item>
|
||||
<item name="windowSplashScreenAnimatedIcon">@drawable/coop</item>
|
||||
<item name="postSplashScreenTheme">@android:style/Theme.Material.Light.NoActionBar</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -1,23 +1,27 @@
|
||||
[versions]
|
||||
agp = "9.2.1"
|
||||
android-compileSdk = "36"
|
||||
android-minSdk = "24"
|
||||
android-targetSdk = "36"
|
||||
android-compileSdk = "37"
|
||||
android-minSdk = "26"
|
||||
android-targetSdk = "37"
|
||||
androidx-activity = "1.13.0"
|
||||
androidx-appcompat = "1.7.1"
|
||||
androidx-core = "1.18.0"
|
||||
androidx-espresso = "3.7.0"
|
||||
androidx-lifecycle = "2.10.0"
|
||||
androidx-navigation = "2.8.8"
|
||||
androidx-testExt = "1.3.0"
|
||||
composeMultiplatform = "1.10.3"
|
||||
androidx-splashscreen = "1.2.0"
|
||||
composeMultiplatform = "1.11.1"
|
||||
datastorePreferences = "1.2.1"
|
||||
junit = "4.13.2"
|
||||
kotlin = "2.3.20"
|
||||
kotlinx-serialization = "1.8.0"
|
||||
material3 = "1.10.0-alpha05"
|
||||
ktor = "3.4.3"
|
||||
kotlin = "2.4.0"
|
||||
kotlinx-serialization = "1.11.0"
|
||||
material3 = "1.11.0-alpha07"
|
||||
multiplatform-nav3-ui = "1.1.1"
|
||||
ktor = "3.5.1"
|
||||
|
||||
[libraries]
|
||||
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" }
|
||||
androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastorePreferences" }
|
||||
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
|
||||
kotlin-testJunit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
|
||||
junit = { module = "junit:junit", version.ref = "junit" }
|
||||
@@ -25,12 +29,13 @@ androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx
|
||||
androidx-testExt-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-testExt" }
|
||||
androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-espresso" }
|
||||
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
|
||||
androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidx-splashscreen" }
|
||||
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
|
||||
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidx-navigation" }
|
||||
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
|
||||
compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" }
|
||||
androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
|
||||
androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
|
||||
androidx-lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "androidx-lifecycle" }
|
||||
compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "composeMultiplatform" }
|
||||
compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "composeMultiplatform" }
|
||||
compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "material3" }
|
||||
@@ -43,6 +48,8 @@ ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "kto
|
||||
ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" }
|
||||
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
|
||||
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
|
||||
jetbrains-navigation3-ui = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "multiplatform-nav3-ui" }
|
||||
jetbrains-lifecycle-viewmodelNavigation3 = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "androidx-lifecycle" }
|
||||
|
||||
[plugins]
|
||||
androidApplication = { id = "com.android.application", version.ref = "agp" }
|
||||
|
||||
@@ -4,4 +4,7 @@ PRODUCT_NAME=Coop
|
||||
PRODUCT_BUNDLE_IDENTIFIER=su.reya.coop.Coop$(TEAM_ID)
|
||||
|
||||
CURRENT_PROJECT_VERSION=1
|
||||
MARKETING_VERSION=1.0
|
||||
MARKETING_VERSION=1.0
|
||||
|
||||
FRAMEWORK_SEARCH_PATHS=$(inherited) "$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)"
|
||||
OTHER_LDFLAGS=$(inherited) -framework Shared
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import Foundation
|
||||
|
||||
enum AppRoute: Hashable {
|
||||
case home
|
||||
case requestList
|
||||
case contactList
|
||||
case updateProfile
|
||||
case newChat
|
||||
case myQr
|
||||
case relay
|
||||
case settings
|
||||
case chat(id: Int64, screening: Bool)
|
||||
case profile(pubkey: String)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import Foundation
|
||||
import Shared
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class AppState {
|
||||
let bootstrap: Bootstrap
|
||||
|
||||
private var subscriptions: [FlowSubscription] = []
|
||||
|
||||
var path: [AppRoute] = []
|
||||
var signerRequired: Bool?
|
||||
var isSyncing = false
|
||||
var partialProcessed = false
|
||||
var chatRooms: [Room] = []
|
||||
var accountState: AccountState?
|
||||
var settings: Settings?
|
||||
var currentUserProfile: Profile?
|
||||
var isUpdatingProfile = false
|
||||
var errorMessage: String?
|
||||
|
||||
let networkMonitor = NetworkMonitor()
|
||||
|
||||
init() {
|
||||
bootstrap = Bootstrap.companion.create(storage: KeychainStorage())
|
||||
}
|
||||
|
||||
func start() {
|
||||
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
let dbDir = docs.appendingPathComponent("nostr", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: dbDir, withIntermediateDirectories: true)
|
||||
|
||||
bootstrap.start(dbPath: dbDir.path) { [weak self] event in
|
||||
self?.handleNewMessage(event)
|
||||
}
|
||||
|
||||
subscriptions.append(bootstrap.watchAccountState { [weak self] state in
|
||||
self?.accountState = state
|
||||
self?.signerRequired = state.signerRequired?.boolValue
|
||||
})
|
||||
subscriptions.append(bootstrap.watchChatRooms { [weak self] rooms in
|
||||
self?.chatRooms = rooms
|
||||
})
|
||||
subscriptions.append(bootstrap.watchIsSyncing { [weak self] value in
|
||||
self?.isSyncing = value.boolValue
|
||||
})
|
||||
subscriptions.append(bootstrap.watchPartialProcessed { [weak self] value in
|
||||
self?.partialProcessed = value.boolValue
|
||||
})
|
||||
subscriptions.append(bootstrap.watchSettings { [weak self] settings in
|
||||
self?.settings = settings
|
||||
})
|
||||
subscriptions.append(bootstrap.watchCurrentUserProfile { [weak self] profile in
|
||||
self?.currentUserProfile = profile
|
||||
})
|
||||
subscriptions.append(bootstrap.watchIsUpdatingProfile { [weak self] value in
|
||||
self?.isUpdatingProfile = value.boolValue
|
||||
})
|
||||
subscriptions.append(bootstrap.watchErrors { [weak self] message in
|
||||
self?.errorMessage = message
|
||||
})
|
||||
}
|
||||
|
||||
func handle(_ url: URL) {
|
||||
guard url.scheme == "coop" else { return }
|
||||
switch url.host {
|
||||
case "chat":
|
||||
if let id = Int64(url.pathComponents.dropFirst().first ?? "") {
|
||||
path.append(.chat(id: id, screening: false))
|
||||
}
|
||||
case "profile":
|
||||
let pubkey = url.pathComponents.dropFirst().first ?? ""
|
||||
if !pubkey.isEmpty {
|
||||
path.append(.profile(pubkey: pubkey))
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func logout() {
|
||||
bootstrap.logout()
|
||||
bootstrap.resetState()
|
||||
path = []
|
||||
}
|
||||
|
||||
func resume() {
|
||||
Task { try? await bootstrap.resume() }
|
||||
}
|
||||
|
||||
func pause() {
|
||||
let taskId = UIApplication.shared.beginBackgroundTask()
|
||||
Task {
|
||||
try? await bootstrap.pause()
|
||||
UIApplication.shared.endBackgroundTask(taskId)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleNewMessage(_ event: Nostr_sdk_kmpUnsignedEvent) {
|
||||
NotificationService.shared.notifyNewMessage(roomId: event.roomId(), content: event.content())
|
||||
}
|
||||
}
|
||||
@@ -2,32 +2,80 @@ import SwiftUI
|
||||
import Shared
|
||||
|
||||
struct ContentView: View {
|
||||
@State private var showContent = false
|
||||
var body: some View {
|
||||
VStack {
|
||||
Button("Click me!") {
|
||||
withAnimation {
|
||||
showContent = !showContent
|
||||
}
|
||||
}
|
||||
@Environment(AppState.self) private var appState
|
||||
|
||||
if showContent {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "swift")
|
||||
.font(.system(size: 200))
|
||||
.foregroundColor(.accentColor)
|
||||
Text("SwiftUI: \(Greeting().greet())")
|
||||
var body: some View {
|
||||
Group {
|
||||
switch appState.signerRequired {
|
||||
case .none:
|
||||
SplashView()
|
||||
case .some(true):
|
||||
OnboardingView()
|
||||
case .some(false):
|
||||
NavigationStack(path: Bindable(appState).path) {
|
||||
HomeView()
|
||||
.navigationDestination(for: AppRoute.self) { route in
|
||||
destination(for: route)
|
||||
}
|
||||
}
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.padding()
|
||||
.alert("Error", isPresented: Binding(
|
||||
get: { appState.errorMessage != nil },
|
||||
set: { if !$0 { appState.errorMessage = nil } }
|
||||
)) {
|
||||
Button("OK") { appState.errorMessage = nil }
|
||||
} message: {
|
||||
Text(appState.errorMessage ?? "")
|
||||
}
|
||||
.preferredColorScheme(colorScheme)
|
||||
}
|
||||
|
||||
private var colorScheme: ColorScheme? {
|
||||
switch appState.settings?.theme {
|
||||
case Theme.light:
|
||||
return .light
|
||||
case Theme.dark:
|
||||
return .dark
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func destination(for route: AppRoute) -> some View {
|
||||
switch route {
|
||||
case .home:
|
||||
HomeView()
|
||||
case .requestList:
|
||||
RequestListView()
|
||||
case .contactList:
|
||||
ContactListView()
|
||||
case .updateProfile:
|
||||
UpdateProfileView()
|
||||
case .newChat:
|
||||
NewChatView()
|
||||
case .myQr:
|
||||
MyQrView()
|
||||
case .relay:
|
||||
RelayView()
|
||||
case .settings:
|
||||
SettingsView()
|
||||
case .chat(let id, let screening):
|
||||
ChatView(roomId: id, screening: screening)
|
||||
case .profile(let pubkey):
|
||||
ProfileView(pubkey: pubkey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ContentView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ContentView()
|
||||
struct SplashView: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color(.systemBackground).ignoresSafeArea()
|
||||
Image(systemName: "bubble.left.and.bubble.right.fill")
|
||||
.font(.system(size: 64))
|
||||
.foregroundStyle(.tint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,5 +4,18 @@
|
||||
<dict>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Coop uses the camera to scan QR codes for adding contacts and importing identities.</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>su.reya.coop</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>coop</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import Foundation
|
||||
import Security
|
||||
import Shared
|
||||
|
||||
final class KeychainStorage: AppStorage {
|
||||
private let defaults = UserDefaults.standard
|
||||
private let service = "su.reya.coop"
|
||||
|
||||
func get(key: String, completionHandler: @escaping (String?, Error?) -> Void) {
|
||||
completionHandler(defaults.string(forKey: key), nil)
|
||||
}
|
||||
|
||||
func set(key: String, value: String, completionHandler: @escaping (Error?) -> Void) {
|
||||
defaults.set(value, forKey: key)
|
||||
completionHandler(nil)
|
||||
}
|
||||
|
||||
func getSecret(key: String, completionHandler: @escaping (String?, Error?) -> Void) {
|
||||
let query: [CFString: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: service,
|
||||
kSecAttrAccount: key,
|
||||
kSecReturnData: true,
|
||||
kSecMatchLimit: kSecMatchLimitOne,
|
||||
]
|
||||
var item: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
||||
guard status == errSecSuccess, let data = item as? Data else {
|
||||
completionHandler(nil, nil)
|
||||
return
|
||||
}
|
||||
completionHandler(String(data: data, encoding: .utf8), nil)
|
||||
}
|
||||
|
||||
func setSecret(key: String, value: String, completionHandler: @escaping (Error?) -> Void) {
|
||||
let data = Data(value.utf8)
|
||||
let query: [CFString: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: service,
|
||||
kSecAttrAccount: key,
|
||||
]
|
||||
if SecItemCopyMatching(query as CFDictionary, nil) == errSecSuccess {
|
||||
SecItemUpdate(query as CFDictionary, [kSecValueData: data] as CFDictionary)
|
||||
} else {
|
||||
var add = query
|
||||
add[kSecValueData] = data
|
||||
add[kSecAttrAccessible] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||
SecItemAdd(add as CFDictionary, nil)
|
||||
}
|
||||
completionHandler(nil)
|
||||
}
|
||||
|
||||
func clear(key: String, completionHandler: @escaping (Error?) -> Void) {
|
||||
defaults.removeObject(forKey: key)
|
||||
let query: [CFString: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: service,
|
||||
kSecAttrAccount: key,
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
completionHandler(nil)
|
||||
}
|
||||
|
||||
func has(key: String, completionHandler: @escaping (KotlinBoolean?, Error?) -> Void) {
|
||||
if defaults.object(forKey: key) != nil {
|
||||
completionHandler(KotlinBoolean(booleanLiteral: true), nil)
|
||||
return
|
||||
}
|
||||
getSecret(key: key) { secret, _ in
|
||||
completionHandler(KotlinBoolean(booleanLiteral: secret != nil), nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import Foundation
|
||||
import Shared
|
||||
|
||||
extension Data {
|
||||
func toKotlinByteArray() -> KotlinByteArray {
|
||||
let array = KotlinByteArray(size: Int32(count))
|
||||
for (index, byte) in enumerated() {
|
||||
array.set(index: Int32(index), value: Int8(bitPattern: byte))
|
||||
}
|
||||
return array
|
||||
}
|
||||
}
|
||||
|
||||
extension KotlinBoolean {
|
||||
var value: Bool { boolValue }
|
||||
}
|
||||
|
||||
extension String {
|
||||
func isImageUrl() -> Bool {
|
||||
ExtensionsKt.isImageUrl(self)
|
||||
}
|
||||
|
||||
func removeImageUrls() -> String {
|
||||
ExtensionsKt.removeImageUrls(self)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
@Observable
|
||||
final class NetworkMonitor {
|
||||
private let monitor = NWPathMonitor()
|
||||
private let queue = DispatchQueue(label: "su.reya.coop.network")
|
||||
|
||||
private(set) var isMobileData = false
|
||||
|
||||
init() {
|
||||
monitor.pathUpdateHandler = { [weak self] path in
|
||||
let mobile = path.usesInterfaceType(.cellular)
|
||||
Task { @MainActor in
|
||||
self?.isMobileData = mobile
|
||||
}
|
||||
}
|
||||
monitor.start(queue: queue)
|
||||
}
|
||||
|
||||
deinit {
|
||||
monitor.cancel()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
|
||||
final class NotificationService: NSObject, UNUserNotificationCenterDelegate {
|
||||
static let shared = NotificationService()
|
||||
|
||||
private let center = UNUserNotificationCenter.current()
|
||||
|
||||
var onOpenChat: ((Int64) -> Void)?
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
center.delegate = self
|
||||
}
|
||||
|
||||
func requestPermissionIfNeeded() {
|
||||
center.getNotificationSettings { settings in
|
||||
if settings.authorizationStatus == .notDetermined {
|
||||
self.center.requestAuthorization(options: [.alert, .badge, .sound]) { _, _ in }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func notifyNewMessage(roomId: Int64, content: String) {
|
||||
let notificationContent = UNMutableNotificationContent()
|
||||
notificationContent.title = "You received a new message"
|
||||
notificationContent.body = content
|
||||
notificationContent.sound = .default
|
||||
notificationContent.userInfo = ["roomId": roomId]
|
||||
|
||||
let request = UNNotificationRequest(
|
||||
identifier: UUID().uuidString,
|
||||
content: notificationContent,
|
||||
trigger: nil
|
||||
)
|
||||
center.add(request)
|
||||
}
|
||||
|
||||
func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
willPresent notification: UNNotification
|
||||
) async -> UNNotificationPresentationOptions {
|
||||
[]
|
||||
}
|
||||
|
||||
func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
didReceive response: UNNotificationResponse
|
||||
) async {
|
||||
if let roomId = response.notification.request.content.userInfo["roomId"] as? Int64 {
|
||||
await MainActor.run {
|
||||
onOpenChat?(roomId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import PhotosUI
|
||||
import SwiftUI
|
||||
import Shared
|
||||
|
||||
struct ChatView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
let roomId: Int64
|
||||
let screening: Bool
|
||||
|
||||
@State private var viewModel: ChatViewModel?
|
||||
@State private var input = ""
|
||||
@State private var photoItem: PhotosPickerItem?
|
||||
@State private var authorProfiles: [String: Profile] = [:]
|
||||
|
||||
private var showScreener: Bool {
|
||||
(viewModel?.requireScreening ?? false) && appState.settings?.screening == true
|
||||
}
|
||||
|
||||
private var isGroup: Bool {
|
||||
viewModel?.room?.isGroup() == true
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
messageList
|
||||
|
||||
if showScreener, let room = viewModel?.room {
|
||||
ScreenerCard(
|
||||
room: room,
|
||||
onAccept: { viewModel?.requireScreening = false },
|
||||
onReject: { dismiss() }
|
||||
)
|
||||
} else {
|
||||
inputBar
|
||||
}
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .principal) {
|
||||
if let room = viewModel?.room, let firstMember = otherMember(of: room) {
|
||||
Button {
|
||||
appState.path.append(.profile(pubkey: firstMember.toHex()))
|
||||
} label: {
|
||||
VStack(spacing: 2) {
|
||||
AvatarView(
|
||||
name: viewModel?.roomUi?.name ?? "?",
|
||||
picture: viewModel?.roomUi?.picture,
|
||||
size: 30
|
||||
)
|
||||
Text(viewModel?.roomUi?.name ?? "Chat")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text(viewModel?.roomUi?.name ?? "Chat").font(.headline)
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
let vm = ChatViewModel(roomId: roomId, screening: screening)
|
||||
viewModel = vm
|
||||
vm.start(appState: appState)
|
||||
}
|
||||
.onDisappear {
|
||||
viewModel?.stop()
|
||||
}
|
||||
.task(id: photoItem) {
|
||||
guard let photoItem else { return }
|
||||
guard let data = try? await photoItem.loadTransferable(type: Data.self) else { return }
|
||||
let contentType = photoItem.supportedContentTypes.first?.preferredMIMEType ?? "image/jpeg"
|
||||
viewModel?.sendImage(data, contentType: contentType, appState: appState)
|
||||
self.photoItem = nil
|
||||
}
|
||||
}
|
||||
|
||||
private var messageList: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 2) {
|
||||
ForEach(groupedMessages, id: \.0) { group, messages in
|
||||
Text(headerTitle(group: group, first: messages.first))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.vertical, 14)
|
||||
|
||||
ForEach(Array(messages.enumerated()), id: \.element.stableId) { index, event in
|
||||
messageCell(event: event, at: index, in: messages)
|
||||
}
|
||||
}
|
||||
|
||||
if let last = viewModel?.messages.last, isMine(last) {
|
||||
Text("Delivered")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
.defaultScrollAnchor(.bottom)
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.overlay {
|
||||
if viewModel?.loading == true {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func messageCell(
|
||||
event: Nostr_sdk_kmpUnsignedEvent,
|
||||
at index: Int,
|
||||
in messages: [Nostr_sdk_kmpUnsignedEvent]
|
||||
) -> some View {
|
||||
let mine = isMine(event)
|
||||
let authorHex = event.author().toHex()
|
||||
let nextIsSameAuthor = index + 1 < messages.count &&
|
||||
messages[index + 1].author().toHex() == authorHex
|
||||
let prevIsSameAuthor = index > 0 &&
|
||||
messages[index - 1].author().toHex() == authorHex
|
||||
let replyId = event.tags().eventIds().first
|
||||
let replied = replyId.flatMap { id in
|
||||
viewModel?.messages.first { $0.id()?.toHex() == id.toHex() }
|
||||
}
|
||||
|
||||
MessageBubble(
|
||||
event: event,
|
||||
isMine: mine,
|
||||
showImages: showImages,
|
||||
isFirstOfRun: !prevIsSameAuthor,
|
||||
isLastOfRun: !nextIsSameAuthor,
|
||||
showAuthorName: isGroup && !mine && !prevIsSameAuthor,
|
||||
showAuthorAvatar: isGroup && !mine,
|
||||
authorName: authorName(for: event),
|
||||
authorPicture: authorProfiles[authorHex]?.picture,
|
||||
repliedMessage: replied,
|
||||
repliedAuthorName: replied.flatMap { authorName(for: $0) },
|
||||
onReply: { viewModel?.replyingTo = event }
|
||||
)
|
||||
.padding(.bottom, nextIsSameAuthor ? 0 : 8)
|
||||
}
|
||||
|
||||
private var inputBar: some View {
|
||||
VStack(spacing: 0) {
|
||||
if let replyingTo = viewModel?.replyingTo {
|
||||
HStack(spacing: 8) {
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(Color.accentColor)
|
||||
.frame(width: 3)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text("Replying to \(authorName(for: replyingTo))")
|
||||
.font(.caption.bold())
|
||||
Text(replyingTo.content())
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
viewModel?.replyingTo = nil
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(.bar)
|
||||
}
|
||||
|
||||
HStack(alignment: .bottom, spacing: 10) {
|
||||
PhotosPicker(selection: $photoItem, matching: .images) {
|
||||
Image(systemName: "plus")
|
||||
.font(.system(size: 18, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 34, height: 34)
|
||||
.background(Color(.secondarySystemFill), in: Circle())
|
||||
}
|
||||
|
||||
TextField("Message", text: $input, axis: .vertical)
|
||||
.lineLimit(1...6)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
.background {
|
||||
Capsule()
|
||||
.strokeBorder(Color(.systemGray4), lineWidth: 1)
|
||||
}
|
||||
|
||||
if !input.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
Button {
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
viewModel?.send(input, appState: appState)
|
||||
input = ""
|
||||
} label: {
|
||||
Image(systemName: "arrow.up.circle.fill")
|
||||
.font(.system(size: 32))
|
||||
.foregroundStyle(Color(.systemBlue))
|
||||
}
|
||||
.transition(.scale.combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(.bar)
|
||||
.animation(.snappy(duration: 0.2), value: input.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
private var groupedMessages: [(String, [Nostr_sdk_kmpUnsignedEvent])] {
|
||||
let messages = viewModel?.messages ?? []
|
||||
var groups: [(String, [Nostr_sdk_kmpUnsignedEvent])] = []
|
||||
var currentKey = ""
|
||||
|
||||
for message in messages {
|
||||
let key = message.createdAt().formatAsGroup()
|
||||
if key != currentKey {
|
||||
groups.append((key, [message]))
|
||||
currentKey = key
|
||||
} else {
|
||||
groups[groups.count - 1].1.append(message)
|
||||
}
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
private func headerTitle(group: String, first: Nostr_sdk_kmpUnsignedEvent?) -> String {
|
||||
guard let first else { return group }
|
||||
return "\(group) \(first.createdAt().formatAsTime())"
|
||||
}
|
||||
|
||||
private var showImages: Bool {
|
||||
guard let media = appState.settings?.media else { return true }
|
||||
switch media {
|
||||
case MediaConfig.disabled:
|
||||
return false
|
||||
case MediaConfig.disabledformobiledata:
|
||||
return !appState.networkMonitor.isMobileData
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private func isMine(_ event: Nostr_sdk_kmpUnsignedEvent) -> Bool {
|
||||
event.author().toHex() == appState.bootstrap.currentPublicKey()?.toHex()
|
||||
}
|
||||
|
||||
private func otherMember(of room: Room) -> Nostr_sdk_kmpPublicKey? {
|
||||
let selfHex = appState.bootstrap.currentPublicKey()?.toHex()
|
||||
return room.members.first { $0.toHex() != selfHex } ?? room.members.first
|
||||
}
|
||||
|
||||
private func authorName(for event: Nostr_sdk_kmpUnsignedEvent) -> String {
|
||||
let hex = event.author().toHex()
|
||||
if hex == appState.bootstrap.currentPublicKey()?.toHex() {
|
||||
return appState.currentUserProfile?.name ?? "You"
|
||||
}
|
||||
if let profile = authorProfiles[hex] {
|
||||
return profile.name
|
||||
}
|
||||
loadAuthorProfile(pubkey: event.author(), hex: hex)
|
||||
return event.author().short()
|
||||
}
|
||||
|
||||
private func loadAuthorProfile(pubkey: Nostr_sdk_kmpPublicKey, hex: String) {
|
||||
guard authorProfiles[hex] == nil else { return }
|
||||
let sub = appState.bootstrap.watchProfile(pubkey: pubkey) { profile in
|
||||
Task { @MainActor in
|
||||
if let profile {
|
||||
authorProfiles[hex] = profile
|
||||
}
|
||||
}
|
||||
}
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(for: .seconds(30))
|
||||
sub.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension Nostr_sdk_kmpUnsignedEvent {
|
||||
var stableId: String {
|
||||
id()?.toHex() ?? "\(createdAt().asSecs())-\(content().hashValue)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import SwiftUI
|
||||
import Shared
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class ChatViewModel {
|
||||
let roomId: Int64
|
||||
|
||||
var messages: [Nostr_sdk_kmpUnsignedEvent] = []
|
||||
var room: Room?
|
||||
var roomUi: RoomUiState?
|
||||
var loading = true
|
||||
var replyingTo: Nostr_sdk_kmpUnsignedEvent?
|
||||
var requireScreening: Bool
|
||||
|
||||
private var subscriptions: [FlowSubscription] = []
|
||||
|
||||
init(roomId: Int64, screening: Bool) {
|
||||
self.roomId = roomId
|
||||
self.requireScreening = screening
|
||||
}
|
||||
|
||||
func start(appState: AppState) {
|
||||
let bootstrap = appState.bootstrap
|
||||
|
||||
room = bootstrap.getChatRoom(id: roomId)
|
||||
if let room {
|
||||
subscriptions.append(bootstrap.watchRoomUi(
|
||||
room: room,
|
||||
currentUser: bootstrap.currentPublicKey()
|
||||
) { [weak self] state in
|
||||
Task { @MainActor in self?.roomUi = state }
|
||||
})
|
||||
}
|
||||
|
||||
subscriptions.append(bootstrap.watchNewEvents { [weak self] event in
|
||||
Task { @MainActor in
|
||||
guard let self, event.roomId() == self.roomId else { return }
|
||||
if !self.messages.contains(where: { $0.id()?.toHex() == event.id()?.toHex() }) {
|
||||
self.messages.append(event)
|
||||
self.messages.sort { $0.createdAt().asSecs() < $1.createdAt().asSecs() }
|
||||
}
|
||||
appState.bootstrap.markRoomRead(id: self.roomId)
|
||||
}
|
||||
})
|
||||
|
||||
Task {
|
||||
let loaded = (try? await bootstrap.loadRoomMessages(roomId: roomId)) ?? []
|
||||
messages = loaded.sorted { $0.createdAt().asSecs() < $1.createdAt().asSecs() }
|
||||
loading = false
|
||||
bootstrap.markRoomRead(id: roomId)
|
||||
bootstrap.connectRoom(id: roomId)
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
subscriptions.forEach { $0.cancel() }
|
||||
subscriptions = []
|
||||
}
|
||||
|
||||
func send(_ text: String, appState: AppState) {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
|
||||
if let replyTo = replyingTo, let replyId = replyTo.id() {
|
||||
appState.bootstrap.sendReplyMessage(roomId: roomId, text: trimmed, replyTo: replyId)
|
||||
} else {
|
||||
appState.bootstrap.sendTextMessage(roomId: roomId, text: trimmed)
|
||||
}
|
||||
replyingTo = nil
|
||||
requireScreening = false
|
||||
}
|
||||
|
||||
func sendImage(_ data: Data, contentType: String, appState: AppState) {
|
||||
appState.bootstrap.sendImageMessage(
|
||||
roomId: roomId,
|
||||
file: data.toKotlinByteArray(),
|
||||
contentType: contentType
|
||||
)
|
||||
requireScreening = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import SwiftUI
|
||||
import Shared
|
||||
|
||||
struct MessageBubble: View {
|
||||
let event: Nostr_sdk_kmpUnsignedEvent
|
||||
let isMine: Bool
|
||||
let showImages: Bool
|
||||
let isFirstOfRun: Bool
|
||||
let isLastOfRun: Bool
|
||||
let showAuthorName: Bool
|
||||
let showAuthorAvatar: Bool
|
||||
let authorName: String?
|
||||
let authorPicture: String?
|
||||
let repliedMessage: Nostr_sdk_kmpUnsignedEvent?
|
||||
let repliedAuthorName: String?
|
||||
let onReply: () -> Void
|
||||
|
||||
@State private var showTimestamp = false
|
||||
|
||||
private var imageUrls: [URL] {
|
||||
guard showImages else { return [] }
|
||||
return event.content()
|
||||
.components(separatedBy: .whitespacesAndNewlines)
|
||||
.filter { $0.isImageUrl() }
|
||||
.compactMap { URL(string: $0) }
|
||||
}
|
||||
|
||||
private var text: String {
|
||||
showImages ? event.content().removeImageUrls() : event.content()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .bottom, spacing: 6) {
|
||||
if isMine {
|
||||
Spacer(minLength: 48)
|
||||
} else {
|
||||
avatarGutter
|
||||
}
|
||||
|
||||
VStack(alignment: isMine ? .trailing : .leading, spacing: 2) {
|
||||
if showAuthorName, let authorName {
|
||||
Text(authorName)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.leading, 16)
|
||||
}
|
||||
|
||||
if let repliedMessage {
|
||||
replyPreview(repliedMessage)
|
||||
}
|
||||
|
||||
ForEach(imageUrls, id: \.absoluteString) { url in
|
||||
AsyncImage(url: url) { phase in
|
||||
switch phase {
|
||||
case .success(let image):
|
||||
image.resizable().scaledToFit()
|
||||
case .failure:
|
||||
ContentUnavailableView("Image unavailable", systemImage: "photo.badge.exclamationmark")
|
||||
.frame(height: 120)
|
||||
default:
|
||||
ProgressView()
|
||||
.frame(height: 120)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 260)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
}
|
||||
|
||||
if !text.isEmpty {
|
||||
Text(text)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
.foregroundStyle(isMine ? .white : .primary)
|
||||
.background(
|
||||
isMine ? Color(.systemBlue) : Color(.systemGray5),
|
||||
in: BubbleShape(isMine: isMine, tail: isLastOfRun)
|
||||
)
|
||||
}
|
||||
|
||||
if showTimestamp {
|
||||
Text(event.createdAt().formatAsTime())
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
}
|
||||
|
||||
if !isMine { Spacer(minLength: 48) }
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
withAnimation(.easeInOut(duration: 0.15)) {
|
||||
showTimestamp.toggle()
|
||||
}
|
||||
}
|
||||
.contextMenu {
|
||||
Button {
|
||||
UIPasteboard.general.string = event.content()
|
||||
} label: {
|
||||
Label("Copy", systemImage: "doc.on.doc")
|
||||
}
|
||||
Button {
|
||||
onReply()
|
||||
} label: {
|
||||
Label("Reply", systemImage: "arrowshape.turn.up.left")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var avatarGutter: some View {
|
||||
if isLastOfRun, showAuthorAvatar {
|
||||
AvatarView(name: authorName ?? "?", picture: authorPicture, size: 26)
|
||||
} else {
|
||||
Color.clear.frame(width: 26, height: 26)
|
||||
}
|
||||
}
|
||||
|
||||
private func replyPreview(_ replied: Nostr_sdk_kmpUnsignedEvent) -> some View {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(repliedAuthorName ?? "Unknown")
|
||||
.font(.caption2.bold())
|
||||
Text(replied.content())
|
||||
.font(.caption)
|
||||
.lineLimit(2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(
|
||||
isMine ? Color(.systemBlue).opacity(0.7) : Color(.systemGray4),
|
||||
in: RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
)
|
||||
.foregroundStyle(isMine ? .white : .primary)
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
}
|
||||
|
||||
struct BubbleShape: Shape {
|
||||
let isMine: Bool
|
||||
let tail: Bool
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
let radius: CGFloat = 18
|
||||
let small: CGFloat = tail ? 4 : radius
|
||||
|
||||
let topLeft: CGFloat = radius
|
||||
let topRight: CGFloat = radius
|
||||
let bottomLeft: CGFloat = isMine ? radius : small
|
||||
let bottomRight: CGFloat = isMine ? small : radius
|
||||
|
||||
var path = Path()
|
||||
path.move(to: CGPoint(x: rect.minX + topLeft, y: rect.minY))
|
||||
path.addLine(to: CGPoint(x: rect.maxX - topRight, y: rect.minY))
|
||||
path.addArc(
|
||||
center: CGPoint(x: rect.maxX - topRight, y: rect.minY + topRight),
|
||||
radius: topRight, startAngle: .degrees(-90), endAngle: .degrees(0), clockwise: false
|
||||
)
|
||||
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - bottomRight))
|
||||
path.addArc(
|
||||
center: CGPoint(x: rect.maxX - bottomRight, y: rect.maxY - bottomRight),
|
||||
radius: bottomRight, startAngle: .degrees(0), endAngle: .degrees(90), clockwise: false
|
||||
)
|
||||
path.addLine(to: CGPoint(x: rect.minX + bottomLeft, y: rect.maxY))
|
||||
path.addArc(
|
||||
center: CGPoint(x: rect.minX + bottomLeft, y: rect.maxY - bottomLeft),
|
||||
radius: bottomLeft, startAngle: .degrees(90), endAngle: .degrees(180), clockwise: false
|
||||
)
|
||||
path.addLine(to: CGPoint(x: rect.minX, y: rect.minY + topLeft))
|
||||
path.addArc(
|
||||
center: CGPoint(x: rect.minX + topLeft, y: rect.minY + topLeft),
|
||||
radius: topLeft, startAngle: .degrees(180), endAngle: .degrees(270), clockwise: false
|
||||
)
|
||||
path.closeSubpath()
|
||||
return path
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import SwiftUI
|
||||
import Shared
|
||||
|
||||
struct ScreenerCard: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
|
||||
let room: Room
|
||||
let onAccept: () -> Void
|
||||
let onReject: () -> Void
|
||||
|
||||
@State private var profile: Profile?
|
||||
@State private var isContact: Bool?
|
||||
@State private var mutualCount: Int?
|
||||
@State private var lastActivity: Nostr_sdk_kmpTimestamp?
|
||||
@State private var subscription: FlowSubscription?
|
||||
|
||||
private var member: Nostr_sdk_kmpPublicKey? {
|
||||
let selfHex = appState.bootstrap.currentPublicKey()?.toHex()
|
||||
return room.members.first { $0.toHex() != selfHex } ?? room.members.first
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
AvatarView(
|
||||
name: profile?.name ?? member?.short() ?? "?",
|
||||
picture: profile?.picture,
|
||||
size: 80
|
||||
)
|
||||
|
||||
Text(profile?.name ?? member?.short() ?? "Unknown")
|
||||
.font(.headline)
|
||||
|
||||
VStack(spacing: 8) {
|
||||
indicator(
|
||||
title: "In your contacts",
|
||||
value: isContact.map { $0 ? "Yes" : "No" },
|
||||
positive: isContact == true
|
||||
)
|
||||
indicator(
|
||||
title: "Mutual contacts",
|
||||
value: mutualCount.map { "\($0)" },
|
||||
positive: (mutualCount ?? 0) > 0
|
||||
)
|
||||
indicator(
|
||||
title: "Last public activity",
|
||||
value: lastActivity?.humanReadable(),
|
||||
positive: lastActivity != nil
|
||||
)
|
||||
}
|
||||
.font(.subheadline)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button(role: .destructive, action: onReject) {
|
||||
Text("Reject")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Button(action: onAccept) {
|
||||
Text("Accept")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 16))
|
||||
.padding()
|
||||
.task {
|
||||
guard let member else { return }
|
||||
subscription = appState.bootstrap.watchProfile(pubkey: member) { value in
|
||||
Task { @MainActor in profile = value }
|
||||
}
|
||||
isContact = (try? await appState.bootstrap.verifyContact(pubkey: member))?.boolValue
|
||||
mutualCount = (try? await appState.bootstrap.mutualContacts(pubkey: member))?.count
|
||||
lastActivity = try? await appState.bootstrap.verifyActivity(pubkey: member)
|
||||
}
|
||||
.onDisappear {
|
||||
subscription?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
private func indicator(title: String, value: String?, positive: Bool) -> some View {
|
||||
HStack {
|
||||
Image(systemName: positive ? "checkmark.circle.fill" : "xmark.circle.fill")
|
||||
.foregroundStyle(positive ? .green : .secondary)
|
||||
Text(title)
|
||||
Spacer()
|
||||
if let value {
|
||||
Text(value).foregroundStyle(.secondary)
|
||||
} else {
|
||||
ProgressView().controlSize(.mini)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AvatarView: View {
|
||||
let name: String
|
||||
let picture: String?
|
||||
var size: CGFloat = 44
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let picture, let url = URL(string: picture), !picture.isEmpty {
|
||||
AsyncImage(url: url) { phase in
|
||||
switch phase {
|
||||
case .success(let image):
|
||||
image.resizable().scaledToFill()
|
||||
default:
|
||||
placeholder
|
||||
}
|
||||
}
|
||||
} else {
|
||||
placeholder
|
||||
}
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
|
||||
private var placeholder: some View {
|
||||
ZStack {
|
||||
Circle().fill(Color(.secondarySystemFill))
|
||||
Text(name.prefix(1).uppercased())
|
||||
.font(.system(size: size * 0.45, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import SwiftUI
|
||||
import Shared
|
||||
|
||||
struct ContactListView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
@State private var showAddContact = false
|
||||
@State private var showScanner = false
|
||||
@State private var newContact = ""
|
||||
@State private var validating = false
|
||||
@State private var validationError: String?
|
||||
@State private var removing: Nostr_sdk_kmpPublicKey?
|
||||
|
||||
private var contacts: [Nostr_sdk_kmpPublicKey] {
|
||||
Array(appState.accountState?.contactList ?? [])
|
||||
.sorted { $0.toHex() < $1.toHex() }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
ForEach(contacts, id: \.self) { pubkey in
|
||||
Button {
|
||||
openChat(with: pubkey)
|
||||
} label: {
|
||||
ContactRow(pubkey: pubkey)
|
||||
}
|
||||
.tint(.primary)
|
||||
.swipeActions(edge: .trailing) {
|
||||
Button(role: .destructive) {
|
||||
removing = pubkey
|
||||
} label: {
|
||||
Label("Remove", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
if contacts.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No contacts",
|
||||
systemImage: "person.2",
|
||||
description: Text("Add contacts to start chatting")
|
||||
)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Contacts")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
HStack {
|
||||
Button { showScanner = true } label: {
|
||||
Image(systemName: "qrcode.viewfinder")
|
||||
}
|
||||
Button { showAddContact = true } label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showScanner) {
|
||||
ScanView { result in
|
||||
showScanner = false
|
||||
if let pubkey = appState.bootstrap.parsePublicKey(input: result) {
|
||||
openChat(with: pubkey)
|
||||
} else {
|
||||
appState.errorMessage = "Invalid public key"
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert("Add Contact", isPresented: $showAddContact) {
|
||||
TextField("npub or user@domain", text: $newContact)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
Button("Cancel", role: .cancel) {
|
||||
newContact = ""
|
||||
validationError = nil
|
||||
}
|
||||
Button("Add") {
|
||||
addContact()
|
||||
}
|
||||
.disabled(newContact.isEmpty || validating)
|
||||
} message: {
|
||||
if let validationError {
|
||||
Text(validationError)
|
||||
} else {
|
||||
Text("Enter a nostr public key (npub) or NIP-05 address")
|
||||
}
|
||||
}
|
||||
.alert("Remove Contact?", isPresented: Binding(
|
||||
get: { removing != nil },
|
||||
set: { if !$0 { removing = nil } }
|
||||
)) {
|
||||
Button("Cancel", role: .cancel) { removing = nil }
|
||||
Button("Remove", role: .destructive) {
|
||||
if let removing {
|
||||
appState.bootstrap.removeContact(publicKey: removing)
|
||||
}
|
||||
removing = nil
|
||||
}
|
||||
} message: {
|
||||
Text("This contact will be removed from your list.")
|
||||
}
|
||||
}
|
||||
|
||||
private func addContact() {
|
||||
let value = newContact.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
validating = true
|
||||
Task {
|
||||
defer { validating = false }
|
||||
if appState.bootstrap.parsePublicKey(input: value) != nil {
|
||||
appState.bootstrap.addContact(address: value)
|
||||
newContact = ""
|
||||
} else if value.contains("@"),
|
||||
(try? await appState.bootstrap.searchByAddress(query: value)) != nil {
|
||||
appState.bootstrap.addContact(address: value)
|
||||
newContact = ""
|
||||
} else {
|
||||
validationError = "Could not find this user. Check the address and try again."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func openChat(with pubkey: Nostr_sdk_kmpPublicKey) {
|
||||
do {
|
||||
let roomId = try appState.bootstrap.createChatRoom(recipients: [pubkey])
|
||||
appState.path.append(.chat(id: roomId, screening: false))
|
||||
} catch {
|
||||
appState.errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import SwiftUI
|
||||
import Shared
|
||||
|
||||
struct HomeView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
@State private var showProfileSheet = false
|
||||
@State private var showRelayWarning = false
|
||||
|
||||
private var ongoingRooms: [Room] {
|
||||
appState.chatRooms.filter { $0.kind == RoomKind.ongoing }
|
||||
}
|
||||
|
||||
private var requestRooms: [Room] {
|
||||
appState.chatRooms.filter { $0.kind == RoomKind.request }
|
||||
}
|
||||
|
||||
private var requestUnread: Int {
|
||||
requestRooms.reduce(0) { $0 + Int($1.unreadCount) }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
if !requestRooms.isEmpty {
|
||||
Button {
|
||||
appState.path.append(.requestList)
|
||||
} label: {
|
||||
HStack(spacing: 10) {
|
||||
Circle()
|
||||
.fill(requestUnread > 0 ? Color.accentColor : .clear)
|
||||
.frame(width: 10, height: 10)
|
||||
|
||||
Image(systemName: "tray.full")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 48, height: 48)
|
||||
.background(Color(.secondarySystemFill), in: Circle())
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("Message Requests")
|
||||
.font(.body.weight(requestUnread > 0 ? .semibold : .regular))
|
||||
Text("\(requestRooms.count)")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(Color(.systemGray3))
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
.tint(.primary)
|
||||
}
|
||||
|
||||
ForEach(ongoingRooms, id: \.id) { room in
|
||||
Button {
|
||||
appState.path.append(.chat(id: room.id, screening: false))
|
||||
} label: {
|
||||
RoomRow(room: room)
|
||||
}
|
||||
.tint(.primary)
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.refreshable {
|
||||
appState.bootstrap.refreshChatRooms()
|
||||
}
|
||||
.overlay {
|
||||
if !appState.partialProcessed {
|
||||
ProgressView()
|
||||
} else if appState.chatRooms.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Messages",
|
||||
systemImage: "bubble.left.and.bubble.right",
|
||||
description: Text("Start a new conversation")
|
||||
)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Coop")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button {
|
||||
showProfileSheet = true
|
||||
} label: {
|
||||
AvatarView(
|
||||
name: appState.currentUserProfile?.name ?? "?",
|
||||
picture: appState.currentUserProfile?.picture,
|
||||
size: 30
|
||||
)
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
HStack(spacing: 16) {
|
||||
if appState.isSyncing {
|
||||
ProgressView().controlSize(.small)
|
||||
}
|
||||
Button {
|
||||
appState.path.append(.newChat)
|
||||
} label: {
|
||||
Image(systemName: "square.and.pencil")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showProfileSheet) {
|
||||
ProfileSheetView()
|
||||
.presentationDetents([.medium, .large])
|
||||
}
|
||||
.sheet(isPresented: $showRelayWarning) {
|
||||
RelayWarningSheet()
|
||||
}
|
||||
.onChange(of: appState.accountState?.isRelayListEmpty) { _, isEmpty in
|
||||
showRelayWarning = isEmpty == true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ProfileSheetView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var showLogoutConfirm = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section {
|
||||
HStack(spacing: 16) {
|
||||
AvatarView(
|
||||
name: appState.currentUserProfile?.name ?? "?",
|
||||
picture: appState.currentUserProfile?.picture,
|
||||
size: 64
|
||||
)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(appState.currentUserProfile?.name ?? "Loading...")
|
||||
.font(.headline)
|
||||
if let npub = try? appState.bootstrap.currentPublicKey()?.toBech32() {
|
||||
Text(npub)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
|
||||
Button {
|
||||
dismiss()
|
||||
appState.path.append(.myQr)
|
||||
} label: {
|
||||
Label("Show QR Code", systemImage: "qrcode")
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button {
|
||||
dismiss()
|
||||
appState.path.append(.updateProfile)
|
||||
} label: {
|
||||
Label("Update Profile", systemImage: "person.crop.circle")
|
||||
}
|
||||
Button {
|
||||
dismiss()
|
||||
appState.path.append(.contactList)
|
||||
} label: {
|
||||
Label("Contact List", systemImage: "person.2")
|
||||
}
|
||||
Button {
|
||||
dismiss()
|
||||
appState.path.append(.relay)
|
||||
} label: {
|
||||
Label("Relay Management", systemImage: "globe")
|
||||
}
|
||||
Button {
|
||||
dismiss()
|
||||
appState.path.append(.settings)
|
||||
} label: {
|
||||
Label("Settings", systemImage: "gear")
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button(role: .destructive) {
|
||||
showLogoutConfirm = true
|
||||
} label: {
|
||||
Label("Logout", systemImage: "rectangle.portrait.and.arrow.right")
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Profile")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.alert("Logout?", isPresented: $showLogoutConfirm) {
|
||||
Button("Cancel", role: .cancel) {}
|
||||
Button("Logout", role: .destructive) {
|
||||
dismiss()
|
||||
appState.logout()
|
||||
}
|
||||
} message: {
|
||||
Text("This will delete all local data. Make sure you have backed up your secret key.")
|
||||
}
|
||||
}
|
||||
.tint(.primary)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import SwiftUI
|
||||
|
||||
struct RelayWarningSheet: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 24) {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(.orange)
|
||||
|
||||
Text("No Messaging Relays")
|
||||
.font(.title2.bold())
|
||||
|
||||
Text("You don't have any messaging relays configured. You won't be able to receive messages until this is resolved.")
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
VStack(spacing: 12) {
|
||||
Button {
|
||||
appState.bootstrap.refetchMsgRelays()
|
||||
dismiss()
|
||||
} label: {
|
||||
Text("Retry")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
|
||||
Button {
|
||||
appState.bootstrap.useDefaultMsgRelayList()
|
||||
dismiss()
|
||||
} label: {
|
||||
Text("Use Default")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.large)
|
||||
}
|
||||
}
|
||||
.padding(32)
|
||||
.presentationDetents([.medium])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import SwiftUI
|
||||
import Shared
|
||||
|
||||
struct RequestListView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
|
||||
private var requestRooms: [Room] {
|
||||
appState.chatRooms.filter { $0.kind == RoomKind.request }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
ForEach(requestRooms, id: \.id) { room in
|
||||
Button {
|
||||
appState.path.append(.chat(id: room.id, screening: true))
|
||||
} label: {
|
||||
RoomRow(room: room)
|
||||
}
|
||||
.tint(.primary)
|
||||
}
|
||||
}
|
||||
.refreshable {
|
||||
appState.bootstrap.refreshChatRooms()
|
||||
}
|
||||
.overlay {
|
||||
if requestRooms.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No requests",
|
||||
systemImage: "tray",
|
||||
description: Text("New message requests will appear here")
|
||||
)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Requests")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import SwiftUI
|
||||
import Shared
|
||||
|
||||
struct RoomRow: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
let room: Room
|
||||
@State private var ui: RoomUiState?
|
||||
@State private var subscription: FlowSubscription?
|
||||
|
||||
private var unread: Bool { room.unreadCount > 0 }
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Circle()
|
||||
.fill(unread ? Color.accentColor : .clear)
|
||||
.frame(width: 10, height: 10)
|
||||
|
||||
AvatarView(name: ui?.name ?? "?", picture: ui?.picture, size: 48)
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(ui?.name ?? "Loading...")
|
||||
.font(.body.weight(unread ? .semibold : .regular))
|
||||
.lineLimit(1)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(room.createdAt.ago())
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(Color(.systemGray3))
|
||||
}
|
||||
|
||||
Text(room.lastMessage ?? "")
|
||||
.font(.subheadline.weight(unread ? .semibold : .regular))
|
||||
.foregroundStyle(unread ? .primary : .secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.task {
|
||||
subscription = appState.bootstrap.watchRoomUi(
|
||||
room: room,
|
||||
currentUser: appState.bootstrap.currentPublicKey()
|
||||
) { state in
|
||||
Task { @MainActor in ui = state }
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
subscription?.cancel()
|
||||
subscription = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import SwiftUI
|
||||
import Shared
|
||||
|
||||
struct ContactRow: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
let pubkey: Nostr_sdk_kmpPublicKey
|
||||
@State private var profile: Profile?
|
||||
@State private var subscription: FlowSubscription?
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
AvatarView(name: profile?.name ?? "?", picture: profile?.picture)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(profile?.name ?? "Loading...")
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
Text(pubkey.short())
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
.task {
|
||||
subscription = appState.bootstrap.watchProfile(pubkey: pubkey) { value in
|
||||
Task { @MainActor in profile = value }
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
subscription?.cancel()
|
||||
subscription = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct NewChatView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
@State private var query = ""
|
||||
@State private var searchResults: [Nostr_sdk_kmpPublicKey] = []
|
||||
@State private var searching = false
|
||||
@State private var selected: [Nostr_sdk_kmpPublicKey] = []
|
||||
@State private var showScanner = false
|
||||
@State private var searchTask: Task<Void, Never>?
|
||||
|
||||
private var contacts: [Nostr_sdk_kmpPublicKey] {
|
||||
Array(appState.accountState?.contactList ?? [])
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
if !selected.isEmpty {
|
||||
Section("To:") {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack {
|
||||
ForEach(selected, id: \.self) { pubkey in
|
||||
Button {
|
||||
selected.removeAll { $0.toHex() == pubkey.toHex() }
|
||||
} label: {
|
||||
Label(pubkey.short(), systemImage: "xmark.circle.fill")
|
||||
.font(.subheadline)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color(.secondarySystemBackground), in: Capsule())
|
||||
}
|
||||
.tint(.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if searching {
|
||||
HStack {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
let results = query.isEmpty ? contacts : searchResults
|
||||
Section(query.isEmpty ? "Contacts" : "Results") {
|
||||
ForEach(results, id: \.self) { pubkey in
|
||||
Button {
|
||||
openChat(with: pubkey)
|
||||
} label: {
|
||||
ContactRow(pubkey: pubkey)
|
||||
}
|
||||
.tint(.primary)
|
||||
.swipeActions(edge: .leading) {
|
||||
Button {
|
||||
toggleSelection(pubkey)
|
||||
} label: {
|
||||
Label("Select", systemImage: "checkmark.circle")
|
||||
}
|
||||
.tint(.accentColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("New Chat")
|
||||
.searchable(text: $query, prompt: "npub, user@domain, or name")
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.onChange(of: query) { _, newValue in
|
||||
searchTask?.cancel()
|
||||
searchTask = Task {
|
||||
try? await Task.sleep(for: .milliseconds(500))
|
||||
guard !Task.isCancelled else { return }
|
||||
await performSearch(newValue)
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
HStack {
|
||||
Button {
|
||||
showScanner = true
|
||||
} label: {
|
||||
Image(systemName: "qrcode.viewfinder")
|
||||
}
|
||||
if !selected.isEmpty {
|
||||
Button("Next") {
|
||||
createGroupChat()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showScanner) {
|
||||
ScanView { result in
|
||||
showScanner = false
|
||||
if let pubkey = appState.bootstrap.parsePublicKey(input: result) {
|
||||
openChat(with: pubkey)
|
||||
} else {
|
||||
appState.errorMessage = "Invalid public key"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func performSearch(_ value: String) async {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard trimmed.count >= 3 else {
|
||||
searchResults = []
|
||||
return
|
||||
}
|
||||
|
||||
searching = true
|
||||
defer { searching = false }
|
||||
|
||||
if trimmed.hasPrefix("npub1") {
|
||||
if let pubkey = appState.bootstrap.parsePublicKey(input: trimmed) {
|
||||
searchResults = [pubkey]
|
||||
}
|
||||
} else if trimmed.contains("@") {
|
||||
if let pubkey = try? await appState.bootstrap.searchByAddress(query: trimmed) {
|
||||
searchResults = [pubkey]
|
||||
} else {
|
||||
searchResults = []
|
||||
}
|
||||
} else {
|
||||
searchResults = (try? await appState.bootstrap.searchByNostr(query: trimmed)) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
private func toggleSelection(_ pubkey: Nostr_sdk_kmpPublicKey) {
|
||||
if let index = selected.firstIndex(where: { $0.toHex() == pubkey.toHex() }) {
|
||||
selected.remove(at: index)
|
||||
} else {
|
||||
selected.append(pubkey)
|
||||
}
|
||||
}
|
||||
|
||||
private func openChat(with pubkey: Nostr_sdk_kmpPublicKey) {
|
||||
do {
|
||||
let roomId = try appState.bootstrap.createChatRoom(recipients: [pubkey])
|
||||
appState.path.append(.chat(id: roomId, screening: false))
|
||||
} catch {
|
||||
appState.errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func createGroupChat() {
|
||||
do {
|
||||
let roomId = try appState.bootstrap.createChatRoom(recipients: selected)
|
||||
appState.path.append(.chat(id: roomId, screening: false))
|
||||
} catch {
|
||||
appState.errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ImportView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
@State private var secret = ""
|
||||
@State private var password = ""
|
||||
@State private var showScanner = false
|
||||
|
||||
private var needsPassword: Bool {
|
||||
secret.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("ncryptsec1")
|
||||
}
|
||||
|
||||
private var canImport: Bool {
|
||||
!secret.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
|
||||
(!needsPassword || !password.isEmpty) &&
|
||||
appState.accountState?.isImporting != true
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
HStack {
|
||||
SecureField("nsec1... or bunker://", text: $secret)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
Button {
|
||||
showScanner = true
|
||||
} label: {
|
||||
Image(systemName: "qrcode.viewfinder")
|
||||
}
|
||||
}
|
||||
|
||||
if needsPassword {
|
||||
SecureField("Decrypt Password", text: $password)
|
||||
}
|
||||
} header: {
|
||||
Text("Secret Key")
|
||||
} footer: {
|
||||
Text("Enter your nsec, ncryptsec (with password), or bunker:// connection string.")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Import Identity")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Import") {
|
||||
appState.bootstrap.importIdentity(
|
||||
secret: secret.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
password: needsPassword ? password : nil
|
||||
)
|
||||
}
|
||||
.disabled(!canImport)
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
if appState.accountState?.isImporting == true {
|
||||
ProgressView()
|
||||
.controlSize(.large)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showScanner) {
|
||||
ScanView { result in
|
||||
secret = result
|
||||
showScanner = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import SwiftUI
|
||||
|
||||
struct NewIdentityView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
|
||||
var body: some View {
|
||||
ProfileEditorForm(
|
||||
title: "Create a new identity",
|
||||
confirmLabel: "Continue",
|
||||
isSubmitting: appState.accountState?.isImporting == true
|
||||
) { name, bio, picture, contentType in
|
||||
appState.bootstrap.createIdentity(
|
||||
name: name,
|
||||
bio: bio,
|
||||
picture: picture,
|
||||
contentType: contentType
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import SwiftUI
|
||||
|
||||
private enum OnboardingRoute: Hashable {
|
||||
case newIdentity
|
||||
case importIdentity
|
||||
}
|
||||
|
||||
struct OnboardingView: View {
|
||||
@State private var path: [OnboardingRoute] = []
|
||||
|
||||
var body: some View {
|
||||
NavigationStack(path: $path) {
|
||||
VStack(spacing: 32) {
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "bubble.left.and.bubble.right.fill")
|
||||
.font(.system(size: 80))
|
||||
.foregroundStyle(.tint)
|
||||
|
||||
VStack(spacing: 8) {
|
||||
Text("Coop")
|
||||
.font(.largeTitle.bold())
|
||||
Text("Simple, fast, and reliable nostr messaging")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 12) {
|
||||
Button {
|
||||
path.append(.newIdentity)
|
||||
} label: {
|
||||
Text("Start Messaging")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
|
||||
Button {
|
||||
path.append(.importIdentity)
|
||||
} label: {
|
||||
Text("Add an Existing Identity")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.large)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
Text("By continuing, you agree to our Terms of Service and Privacy Policy")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.padding()
|
||||
.navigationDestination(for: OnboardingRoute.self) { route in
|
||||
switch route {
|
||||
case .newIdentity:
|
||||
NewIdentityView()
|
||||
case .importIdentity:
|
||||
ImportView()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import PhotosUI
|
||||
import SwiftUI
|
||||
import Shared
|
||||
|
||||
struct ProfileEditorForm: View {
|
||||
let title: String
|
||||
let confirmLabel: String
|
||||
let initialName: String
|
||||
let initialBio: String
|
||||
let initialPicture: String?
|
||||
let isSubmitting: Bool
|
||||
let onConfirm: (String, String?, KotlinByteArray?, String?) -> Void
|
||||
|
||||
@State private var name: String
|
||||
@State private var bio: String
|
||||
@State private var photoItem: PhotosPickerItem?
|
||||
@State private var photoData: Data?
|
||||
@State private var photoContentType: String?
|
||||
|
||||
init(
|
||||
title: String,
|
||||
confirmLabel: String,
|
||||
initialName: String = "",
|
||||
initialBio: String = "",
|
||||
initialPicture: String? = nil,
|
||||
isSubmitting: Bool = false,
|
||||
onConfirm: @escaping (String, String?, KotlinByteArray?, String?) -> Void
|
||||
) {
|
||||
self.title = title
|
||||
self.confirmLabel = confirmLabel
|
||||
self.initialName = initialName
|
||||
self.initialBio = initialBio
|
||||
self.initialPicture = initialPicture
|
||||
self.isSubmitting = isSubmitting
|
||||
self.onConfirm = onConfirm
|
||||
_name = State(initialValue: initialName)
|
||||
_bio = State(initialValue: initialBio)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
HStack {
|
||||
Spacer()
|
||||
PhotosPicker(selection: $photoItem, matching: .images) {
|
||||
if let photoData, let uiImage = UIImage(data: photoData) {
|
||||
Image(uiImage: uiImage)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: 120, height: 120)
|
||||
.clipShape(Circle())
|
||||
} else {
|
||||
AvatarView(name: name.isEmpty ? "?" : name, picture: initialPicture, size: 120)
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.tint)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
|
||||
Section {
|
||||
TextField("What others should call you?", text: $name)
|
||||
TextField("Tell others about yourself (optional)", text: $bio, axis: .vertical)
|
||||
.lineLimit(3...6)
|
||||
}
|
||||
|
||||
Section {
|
||||
Button {
|
||||
onConfirm(
|
||||
name.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
bio.isEmpty ? nil : bio,
|
||||
photoData?.toKotlinByteArray(),
|
||||
photoContentType
|
||||
)
|
||||
} label: {
|
||||
HStack {
|
||||
Spacer()
|
||||
if isSubmitting {
|
||||
ProgressView()
|
||||
} else {
|
||||
Text(confirmLabel)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSubmitting)
|
||||
}
|
||||
}
|
||||
.navigationTitle(title)
|
||||
.task(id: photoItem) {
|
||||
guard let photoItem else { return }
|
||||
if let data = try? await photoItem.loadTransferable(type: Data.self) {
|
||||
photoData = data
|
||||
photoContentType = photoItem.supportedContentTypes.first?.preferredMIMEType ?? "image/jpeg"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import CoreImage.CIFilterBuiltins
|
||||
import SwiftUI
|
||||
|
||||
struct MyQrView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
|
||||
private var npub: String? {
|
||||
try? appState.bootstrap.currentPublicKey()?.toBech32()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 24) {
|
||||
Spacer()
|
||||
|
||||
if let npub, let qrImage = generateQrCode(from: npub) {
|
||||
Image(uiImage: qrImage)
|
||||
.interpolation(.none)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(maxWidth: 280)
|
||||
.padding()
|
||||
.background(Color.white, in: RoundedRectangle(cornerRadius: 16))
|
||||
|
||||
Text(npub)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal)
|
||||
|
||||
ShareLink(item: npub) {
|
||||
Label("Share", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
} else {
|
||||
ContentUnavailableView("No identity", systemImage: "qrcode")
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
.navigationTitle("My QR Code")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private func generateQrCode(from string: String) -> UIImage? {
|
||||
let context = CIContext()
|
||||
let filter = CIFilter.qrCodeGenerator()
|
||||
filter.message = Data(string.utf8)
|
||||
filter.correctionLevel = "M"
|
||||
guard let outputImage = filter.outputImage else { return nil }
|
||||
let scaled = outputImage.transformed(by: CGAffineTransform(scaleX: 10, y: 10))
|
||||
guard let cgImage = context.createCGImage(scaled, from: scaled.extent) else { return nil }
|
||||
return UIImage(cgImage: cgImage)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import SwiftUI
|
||||
import Shared
|
||||
|
||||
struct ProfileView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
|
||||
let pubkey: String
|
||||
|
||||
@State private var profile: Profile?
|
||||
@State private var subscription: FlowSubscription?
|
||||
@State private var parsedKey: Nostr_sdk_kmpPublicKey?
|
||||
|
||||
private var record: Nostr_sdk_kmpMetadataRecord? {
|
||||
profile?.metadata.asRecord()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
VStack(spacing: 12) {
|
||||
AvatarView(
|
||||
name: profile?.name ?? "?",
|
||||
picture: profile?.picture,
|
||||
size: 120
|
||||
)
|
||||
Text(profile?.name ?? "No name")
|
||||
.font(.title2.bold())
|
||||
Text(record?.nip05 ?? parsedKey?.short() ?? "")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical)
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
|
||||
Section("Details") {
|
||||
LabeledContent("Username", value: record?.name ?? "None")
|
||||
LabeledContent("Website", value: record?.website ?? "None")
|
||||
LabeledContent("Lightning Address", value: record?.lud16 ?? "None")
|
||||
}
|
||||
|
||||
Section {
|
||||
Button {
|
||||
openChat()
|
||||
} label: {
|
||||
Label("Message", systemImage: "message")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
if let npub = try? parsedKey?.toBech32() {
|
||||
ShareLink(item: npub) {
|
||||
Label("Share", systemImage: "square.and.arrow.up")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
.navigationTitle("Profile")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task {
|
||||
parsedKey = appState.bootstrap.parsePublicKey(input: pubkey)
|
||||
guard let parsedKey else { return }
|
||||
subscription = appState.bootstrap.watchProfile(pubkey: parsedKey) { value in
|
||||
Task { @MainActor in profile = value }
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
subscription?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
private func openChat() {
|
||||
guard let parsedKey else { return }
|
||||
do {
|
||||
let roomId = try appState.bootstrap.createChatRoom(recipients: [parsedKey])
|
||||
appState.path.append(.chat(id: roomId, screening: false))
|
||||
} catch {
|
||||
appState.errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import SwiftUI
|
||||
import Shared
|
||||
|
||||
struct UpdateProfileView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
private var record: Nostr_sdk_kmpMetadataRecord? {
|
||||
appState.currentUserProfile?.metadata.asRecord()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ProfileEditorForm(
|
||||
title: "Update Profile",
|
||||
confirmLabel: "Save changes",
|
||||
initialName: record?.displayName ?? record?.name ?? "",
|
||||
initialBio: record?.about ?? "",
|
||||
initialPicture: appState.currentUserProfile?.picture,
|
||||
isSubmitting: appState.isUpdatingProfile
|
||||
) { name, bio, picture, contentType in
|
||||
appState.bootstrap.updateProfile(
|
||||
name: name,
|
||||
bio: bio,
|
||||
picture: picture,
|
||||
contentType: contentType
|
||||
)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||