diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000000000000000000000000000000000..d41a94043c43d58b1c21d58399d4cc5eaf2a2606 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.gif filter=lfs diff=lfs merge=lfs -text diff --git a/.github/workflows/android-release.yml b/.github/workflows/android-release.yml new file mode 100644 index 0000000000000000000000000000000000000000..888b58b12990d1c2b35900a01cdd09c6d00621d6 --- /dev/null +++ b/.github/workflows/android-release.yml @@ -0,0 +1,130 @@ +name: Android Release +env: + main_project_module: app +on: + push: + tags: + - '*' + workflow_dispatch: +jobs: + build: + permissions: write-all + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + # Set Current Date As Env Variable + - name: Set current date as env variable + run: | + cd Android + echo "date_today=$(date +'%Y-%m-%d')" >> $GITHUB_ENV + + # Set Repository Name As Env Variable + - name: Set repository name as env variable + run: | + cd Android + echo "repository_name=$(echo '${{ github.repository }}' | awk -F '/' '{print $2}')" >> $GITHUB_ENV + + - name: set up JDK 11 + uses: actions/setup-java@v3 + with: + java-version: '11' + distribution: 'temurin' + cache: gradle + + - name: Create file + run: | + cd Android + cat /home/runner/work/RisingPhone/RisingPhone/app/google-services.json | base64 + + - name: Putting data + env: + DATA: ${{ secrets.GOOGLE_SERVICES_JSON }} + run: | + cd Android + echo $DATA > /home/runner/work/RisingPhone/RisingPhone/app/google-services.json + + - name: Grant execute permission for gradlew + run: | + cd Android + chmod +x gradlew + + # Run Tests Build + - name: Run gradle tests + run: | + cd Android + ./gradlew test + + - name: Build with Gradle + run: | + cd Android + ./gradlew build + + - name: Archive lint results + if: always() + uses: actions/upload-artifact@v3 + with: + name: lint-report + path: app/build/reports/lint-results-debug.html + + # Create APK Debug + - name: Build apk debug project (APK) - ${{ env.main_project_module }} module + run: | + cd Android + ./gradlew assembleDebug + + # Create APK Release + - name: Build apk release project (APK) - ${{ env.main_project_module }} module + run: | + cd Android + ./gradlew assemble + + # Create Bundle AAB Release + # Noted for main module build [main_project_module]:bundleRelease + - name: Build app bundle release (AAB) - ${{ env.main_project_module }} module + run: | + cd Android + ./gradlew ${{ env.main_project_module }}:bundleRelease + + # Upload Artifact Build + # Noted For Output [main_project_module]/build/outputs/apk/debug/ + - name: Upload APK Debug - ${{ env.repository_name }} + uses: actions/upload-artifact@v2 + with: + name: ${{ env.date_today }} - ${{ env.playstore_name }} - ${{ env.repository_name }} - APK(s) debug generated + path: ${{ env.main_project_module }}/build/outputs/apk/debug/ + + # Noted For Output [main_project_module]/build/outputs/apk/release/ + - name: Upload APK Release - ${{ env.repository_name }} + uses: actions/upload-artifact@v2 + with: + name: ${{ env.date_today }} - ${{ env.playstore_name }} - ${{ env.repository_name }} - APK(s) release generated + path: ${{ env.main_project_module }}/build/outputs/apk/release/ + + # Noted For Output [main_project_module]/build/outputs/bundle/release/ + - name: Upload AAB (App Bundle) Release - ${{ env.repository_name }} + uses: actions/upload-artifact@v2 + with: + name: ${{ env.date_today }} - ${{ env.playstore_name }} - ${{ env.repository_name }} - App bundle(s) AAB release generated + path: ${{ env.main_project_module }}/build/outputs/bundle/release/ + + - name: Create Release + id: create_release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ github.ref }} + release_name: Release ${{ github.ref }} + draft: false + prerelease: false + + - name: Upload Release Asset + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ${{ env.main_project_module }}/build/outputs/apk/debug/app-debug.apk # Update this with the correct APK path + asset_name: app-release-unsigned.apk + asset_content_type: application/vnd.android.package-archive \ No newline at end of file diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 0000000000000000000000000000000000000000..473dc85676c09216694783ca8576940df0cf2f1d --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,55 @@ +name: Android CI + +on: + push: + branches: [ "main", "develop" ] + pull_request: + branches: [ "main" ] + workflow_dispatch: +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: set up JDK 11 + uses: actions/setup-java@v3 + with: + java-version: '11' + distribution: 'temurin' + cache: gradle + + - name: Create file + run: | + cd Android + cat /home/runner/work/RisingPhone/RisingPhone/app/google-services.json | base64 + + - name: Putting data + env: + DATA: ${{ secrets.GOOGLE_SERVICES_JSON }} + run: | + cd Android + echo $DATA > /home/runner/work/RisingPhone/RisingPhone/app/google-services.json + + - name: Grant execute permission for gradlew + run: | + cd Android + chmod +x gradlew + # Run Tests Build + - name: Run gradle tests + run: | + cd Android + ./gradlew test + + - name: Build with Gradle + run: | + cd Android + ./gradlew build + + - name: Archive lint results + if: always() + uses: actions/upload-artifact@v3 + with: + name: lint-report + path: app/build/reports/lint-results-debug.html \ No newline at end of file diff --git a/.github/workflows/extension_main.yml b/.github/workflows/extension_main.yml new file mode 100644 index 0000000000000000000000000000000000000000..578a511ff4b979566a44f00aa8035870b4adc635 --- /dev/null +++ b/.github/workflows/extension_main.yml @@ -0,0 +1,78 @@ +name: Development + +on: + pull_request: + types: + - opened + - edited + - synchronize + - reopened + workflow_call: + +jobs: + test: + name: Test application + runs-on: ubuntu-22.04 + timeout-minutes: 10 + steps: + - name: "☁️ checkout repository" + uses: actions/checkout@v2 + + - name: "🔧 setup node" + uses: actions/setup-node@v2.1.5 + with: + node-version: 16 + + - name: "🔧 install npm@latest" + run: | + cd Extension + npm i -g npm@latest + + - name: "📦 install dependencies" + uses: bahmutov/npm-install@v1 + + - name: "🔍 run tests" + run: | + cd Extension + npm run test --if-present + + lint: + name: Code standards + runs-on: ubuntu-22.04 + timeout-minutes: 10 + steps: + - name: "☁️ checkout repository" + uses: actions/checkout@v2 + + - name: "🔧 setup node" + uses: actions/setup-node@v2.1.5 + with: + node-version: 16 + + - name: "🔧 install npm@latest" + run: | + cd Extension + npm i -g npm@latest + + - name: "📦 install dependencies" + uses: bahmutov/npm-install@v1 + + - name: "🔍 lint code" + run: | + cd Extension + npm run lint --if-present + + build: + runs-on: ubuntu-22.04 + + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: 16 + - run: | + cd Extension + npm ci + - run: | + cd Extension + npm run build \ No newline at end of file diff --git a/Android/.gitignore b/Android/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..268d2a20dac97cb49f7d421ecdb1e1574b9b7db2 --- /dev/null +++ b/Android/.gitignore @@ -0,0 +1,17 @@ +*.iml +.gradle +/local.properties +/.idea +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties +/app/google-services.json diff --git a/Android/README.md b/Android/README.md new file mode 100644 index 0000000000000000000000000000000000000000..f37eb5fb1e78005c1649007332473f2ef20f50ec --- /dev/null +++ b/Android/README.md @@ -0,0 +1,48 @@ +# RisingAndroid +[![Android CI Develop](https://github.com/ttt246/RisingPhone/actions/workflows/android.yml/badge.svg?branch=develop)](https://github.com/ttt246/RisingPhone/actions/workflows/android.yml) +[![Android CI](https://github.com/ttt246/RisingPhone/actions/workflows/android.yml/badge.svg?branch=main)](https://github.com/ttt246/RisingPhone/actions/workflows/android.yml) + +All complex software including operating systems will need to be rewritten from the ground up to take advantage of machine learning. In our OS, a AI will manage all apps via plugins, which can be prompted by the user. Our plugins can run as an openai plugin, or in our backend. + +### RisingPhone + +

+ +

+ +- 📱 Support for mobile devices to manage all apps via plugin as its launcher. +- 🔗 Multiple API support (Web API for Free and Plus users, GPT-3.5, GPT-4, etc.). +- 🔍 Integration to all mainstream search engines, and custom queries to support additional sites. + +### Features + +| Title | Description | +| ------------ | ------------ | +| General Chat | Users can chat with AI plugins. | +| Open Browser Automatically | If a user is going to open browser, the app opens browser and search what a user wants automatically | +| Image Search System | A user can search image on Android local storage | +| Send SMS | If a user says that send SMS, mobile open SMS editor and a user can send SMS using the editor. | + +[[Rising Brain](https://github.com/ttt246/RisingBrain)] +### Run locally +- Copy google-services.json into app folder of project + +### CI/CD +- set google-services.json to github secrets + + +### Test +- Unit Test +- Instrumented Test + +### Contributing + +Please refer to each project's style and contribution guidelines for submitting patches and additions. In general, we follow the "fork-and-pull" Git workflow. + + 1. **Fork** the repo on GitHub + 2. **Clone** the project to your own machine + 3. **Commit** changes to your own branch + 4. **Push** your work back up to your fork + 5. Submit a **Pull request** so that we can review your changes + +NOTE: Be sure to merge the latest from "upstream" before making a pull request! \ No newline at end of file diff --git a/Android/app/.gitignore b/Android/app/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..42afabfd2abebf31384ca7797186a27a4b7dbee8 --- /dev/null +++ b/Android/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/Android/app/build.gradle b/Android/app/build.gradle new file mode 100644 index 0000000000000000000000000000000000000000..77fcacdddaadf0ca4ece00a89c72a15c6de6cf7a --- /dev/null +++ b/Android/app/build.gradle @@ -0,0 +1,113 @@ +plugins { + id 'com.android.application' + id 'kotlin-android' + id 'com.google.gms.google-services' + id 'kotlin-kapt' +} + +android { + namespace 'com.matthaigh27.chatgptwrapper' + compileSdk 33 + + defaultConfig { + applicationId "com.matthaigh27.chatgptwrapper" + minSdk 28 + targetSdk 33 + versionCode 7 + versionName "1.5" + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + debug { + buildConfigField "String", "BASE_URL", "\"https://smartphone.herokuapp.com/\"" + } + release { + // Use your desired server address for the release version + buildConfigField "String", "BASE_URL", "\"https://chatgptphone.herokuapp.com/\"" + + minifyEnabled true + shrinkResources true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + + buildFeatures { + viewBinding true + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11 + } + + dataBinding { + enabled true + } +} + +dependencies { + // App dependencies + implementation 'androidx.core:core-ktx:1.9.0' + implementation 'androidx.appcompat:appcompat:1.6.0' + implementation 'com.google.android.material:material:1.8.0' + implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + + implementation platform('com.google.firebase:firebase-bom:31.4.0') + implementation 'com.google.android.gms:play-services-gcm:17.0.0' + + implementation 'com.google.firebase:firebase-messaging' + implementation 'com.google.firebase:firebase-analytics' + implementation 'com.google.firebase:firebase-firestore-ktx:24.4.5' + implementation 'com.google.firebase:firebase-firestore:15.0.0' + + implementation 'com.google.firebase:firebase-messaging-ktx' + implementation 'com.google.firebase:firebase-analytics-ktx' + implementation 'com.firebaseui:firebase-ui-storage:7.2.0' + + implementation 'com.squareup.okhttp3:okhttp:3.0.1' + implementation 'com.github.soulqw:CoCo:1.1.2' + implementation 'com.github.dhaval2404:imagepicker:2.1' + implementation 'com.google.firebase:firebase-storage-ktx:20.1.0' + testImplementation 'org.testng:testng:6.9.6' + + // Testing-only dependencies + androidTestImplementation 'androidx.test:core:' + rootProject.coreVersion; + androidTestImplementation 'androidx.test.ext:junit:' + rootProject.extJUnitVersion; + androidTestImplementation 'androidx.test:runner:' + rootProject.runnerVersion; + + testImplementation 'junit:junit:4.13.2' + androidTestImplementation 'androidx.test.ext:junit:1.1.5' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' + + // UiAutomator Testing + androidTestImplementation 'androidx.test.uiautomator:uiautomator:' + rootProject.uiAutomatorVersion; + androidTestImplementation 'org.hamcrest:hamcrest-integration:1.3' + + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.2" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.2" + implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.2.0-alpha05" + + implementation 'com.github.bumptech.glide:glide:4.12.0' + annotationProcessor 'com.github.bumptech.glide:compiler:4.12.0' + + implementation 'com.google.code.gson:gson:2.8.5' + + implementation "androidx.room:room-runtime:$room_version" + annotationProcessor "androidx.room:room-compiler:$room_version" + + // To use room database + implementation "androidx.room:room-ktx:$room_version" + kapt "androidx.room:room-compiler:$room_version" + implementation "androidx.room:room-rxjava2:$room_version" + implementation "androidx.room:room-rxjava3:$room_version" + implementation "androidx.room:room-guava:$room_version" + testImplementation "androidx.room:room-testing:$room_version" + implementation "androidx.room:room-paging:$room_version" + + implementation 'de.hdodenhof:circleimageview:3.1.0' +} diff --git a/Android/app/proguard-rules.pro b/Android/app/proguard-rules.pro new file mode 100644 index 0000000000000000000000000000000000000000..481bb434814107eb79d7a30b676d344b0df2f8ce --- /dev/null +++ b/Android/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/Android/app/src/androidTest/java/com/matthaigh27/chatgptwrapper/ExampleInstrumentedTest.kt b/Android/app/src/androidTest/java/com/matthaigh27/chatgptwrapper/ExampleInstrumentedTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..12b8c0de9ba244f0f7cb4f57e5aac99ec3dea474 --- /dev/null +++ b/Android/app/src/androidTest/java/com/matthaigh27/chatgptwrapper/ExampleInstrumentedTest.kt @@ -0,0 +1,26 @@ +package com.matthaigh27.chatgptwrapper + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class + +ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.matthaigh27.chatgptwrapper", appContext.packageName) + } +} \ No newline at end of file diff --git a/Android/app/src/main/AndroidManifest.xml b/Android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000000000000000000000000000000000000..1084fe2e7f87fcb9dbb470065ec30cc2f390558b --- /dev/null +++ b/Android/app/src/main/AndroidManifest.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Android/app/src/main/gpt_icon-playstore.png b/Android/app/src/main/gpt_icon-playstore.png new file mode 100644 index 0000000000000000000000000000000000000000..1faa58404e7cec85c4e78939100338497ae6bd36 Binary files /dev/null and b/Android/app/src/main/gpt_icon-playstore.png differ diff --git a/Android/app/src/main/java/com/matthaigh27/chatgptwrapper/MyApplication.kt b/Android/app/src/main/java/com/matthaigh27/chatgptwrapper/MyApplication.kt new file mode 100644 index 0000000000000000000000000000000000000000..f34f8430b9e8ffb9d4eeb875d75191e08826d65f --- /dev/null +++ b/Android/app/src/main/java/com/matthaigh27/chatgptwrapper/MyApplication.kt @@ -0,0 +1,105 @@ +package com.matthaigh27.chatgptwrapper + +import android.Manifest +import android.annotation.SuppressLint +import android.app.Application +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.pm.PackageManager +import android.util.Log +import com.google.android.gms.tasks.OnCompleteListener +import com.google.firebase.messaging.FirebaseMessaging +import com.matthaigh27.chatgptwrapper.utils.Constants +import android.provider.Settings +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import java.util.Random + +class MyApplication : Application() { + + private var mFCMToken: String = String() + private var mUUID: String = String() + @SuppressLint("HardwareIds") + override fun onCreate() { + super.onCreate() + + initToken() + // on below line we are getting device id. + mUUID = Settings.Secure.getString(contentResolver, Settings.Secure.ANDROID_ID) + appContext = applicationContext as MyApplication + + Log.v("risingandroid mUUID: ", mUUID) + Log.v("risingandroid FCMToken: ", mFCMToken) + } + + private fun initToken() { + FirebaseMessaging.getInstance().token.addOnCompleteListener(OnCompleteListener { task -> + if (!task.isSuccessful) { + Log.w(Constants.TAG, "Fetching FCM registration token failed", task.exception) + return@OnCompleteListener + } + + /** + * Get new FCM registration token + */ + + /** + * Get new FCM registration token + */ + mFCMToken = task.result + Log.d(Constants.TAG, mFCMToken) + }) + } + + fun getFCMToken(): String { + return this.mFCMToken + } + + fun getUUID(): String { + return this.mUUID + } + + /** + * this shows system notification with message + * @param message to be shown with system notification + */ + fun showNotification(message: String) { + val notificationId: Int = Random().nextInt() + val channelId = "chat_message" + + val builder = NotificationCompat.Builder(this, channelId) + builder.setSmallIcon(R.drawable.ic_notification) + builder.setContentTitle(Constants.TAG) + builder.setContentText(message) + builder.setStyle( + NotificationCompat.BigTextStyle().bigText( + message + ) + ) + builder.priority = NotificationCompat.PRIORITY_DEFAULT + builder.setAutoCancel(true) + + val channelName: CharSequence = "Chat Message" + val channelDescription = "This notification channel is used for chat message notifications" + val importance = NotificationManager.IMPORTANCE_DEFAULT + val channel = NotificationChannel(channelId, channelName, importance) + channel.description = channelDescription + val notificationManager = getSystemService( + NotificationManager::class.java + ) + notificationManager.createNotificationChannel(channel) + val notificationManagerCompat = NotificationManagerCompat.from(this) + if (ActivityCompat.checkSelfPermission( + this, Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED + ) { + return + } + notificationManagerCompat.notify(notificationId, builder.build()) + } + + companion object { + lateinit var appContext: MyApplication + } +} \ No newline at end of file diff --git a/Android/app/src/main/java/com/matthaigh27/chatgptwrapper/activites/HomeActivity.kt b/Android/app/src/main/java/com/matthaigh27/chatgptwrapper/activites/HomeActivity.kt new file mode 100644 index 0000000000000000000000000000000000000000..0a11074e924944adcac75f71f65eaf8a4e31050a --- /dev/null +++ b/Android/app/src/main/java/com/matthaigh27/chatgptwrapper/activites/HomeActivity.kt @@ -0,0 +1,92 @@ +package com.matthaigh27.chatgptwrapper.activites + +import android.Manifest +import android.annotation.SuppressLint +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import android.util.Log +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import com.google.android.material.tabs.TabLayout.TabGravity +import com.matthaigh27.chatgptwrapper.R +import com.matthaigh27.chatgptwrapper.dialogs.CommonConfirmDialog +import com.matthaigh27.chatgptwrapper.fragments.ChatFragment +import java.io.File + + +class HomeActivity : AppCompatActivity() { + + private val PERMISSIONS_REQUEST_CODE = 1 + + private val PERMISSIONS = arrayOf( + Manifest.permission.SEND_SMS, + Manifest.permission.READ_CONTACTS, + Manifest.permission.CALL_PHONE, + Manifest.permission.READ_EXTERNAL_STORAGE + ) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_home) + + requestPermission() + } + + private fun requestPermission() { + val notGrantedPermissions = PERMISSIONS.filter { + checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED + } + + if (notGrantedPermissions.isNotEmpty()) { + if (shouldShowRequestPermissionRationale(notGrantedPermissions[0])) { + // show custom permission rationale + val confirmDialog = CommonConfirmDialog(this) + confirmDialog.setMessage("This app requires SMS, Contacts and Phone permissions to function properly. Please grant the necessary permissions.") + confirmDialog.setOnClickListener(object : + CommonConfirmDialog.OnConfirmButtonClickListener { + override fun onPositiveButtonClick() { + requestPermissions( + notGrantedPermissions.toTypedArray(), PERMISSIONS_REQUEST_CODE + ) + } + + override fun onNegativeButtonClick() { + finish() + } + }) + confirmDialog.show() + } else { + requestPermissions(notGrantedPermissions.toTypedArray(), PERMISSIONS_REQUEST_CODE) + } + } else { + // Permissions already granted, navigate to your desired fragment + navigateToChatFragment() + } + } + + private fun navigateToChatFragment() { + val fragmentTransaction = supportFragmentManager.beginTransaction() + fragmentTransaction.replace(R.id.frame_container, ChatFragment()).commit() + } + + @SuppressLint("MissingSuperCall") + override fun onRequestPermissionsResult( + requestCode: Int, permissions: Array, grantResults: IntArray + ) { + when (requestCode) { + PERMISSIONS_REQUEST_CODE -> { + if (grantResults.all { it == PackageManager.PERMISSION_GRANTED }) { + // Permissions granted, navigate to your desired fragment + navigateToChatFragment() + } else { + requestPermission() + } + return + } + } + } +} + + + diff --git a/Android/app/src/main/java/com/matthaigh27/chatgptwrapper/adapters/ChatAdapter.kt b/Android/app/src/main/java/com/matthaigh27/chatgptwrapper/adapters/ChatAdapter.kt new file mode 100644 index 0000000000000000000000000000000000000000..ee08db3bda773aa3f1b540ea6ff27b177df8c522 --- /dev/null +++ b/Android/app/src/main/java/com/matthaigh27/chatgptwrapper/adapters/ChatAdapter.kt @@ -0,0 +1,337 @@ +package com.matthaigh27.chatgptwrapper.adapters + +import android.annotation.SuppressLint +import android.app.Dialog +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.recyclerview.widget.RecyclerView +import com.matthaigh27.chatgptwrapper.R +import com.matthaigh27.chatgptwrapper.models.common.ContactModel +import com.matthaigh27.chatgptwrapper.models.common.HelpPromptModel +import com.matthaigh27.chatgptwrapper.models.viewmodels.ChatMessageModel +import com.matthaigh27.chatgptwrapper.utils.Constants.MSG_WIDGET_TYPE_HELP_PRMOPT +import com.matthaigh27.chatgptwrapper.utils.Constants.MSG_WIDGET_TYPE_SEARCH_CONTACT +import com.matthaigh27.chatgptwrapper.utils.Constants.MSG_WIDGET_TYPE_SMS +import com.matthaigh27.chatgptwrapper.utils.ImageHelper +import com.matthaigh27.chatgptwrapper.utils.Utils +import com.matthaigh27.chatgptwrapper.widgets.ContactDetailItem +import com.matthaigh27.chatgptwrapper.widgets.ContactDetailWidget +import com.matthaigh27.chatgptwrapper.widgets.HelpPromptWidget +import com.matthaigh27.chatgptwrapper.widgets.SearchContactWidget +import com.matthaigh27.chatgptwrapper.widgets.SmsEditorWidget +import org.json.JSONArray + +class ChatAdapter(list: ArrayList, context: Context) : + RecyclerView.Adapter() { + + private var mChatModelList: ArrayList = ArrayList() + private var mContext: Context + + private var mListener: MessageWidgetListener? = null + var mOnSMSClickListener: ContactDetailItem.OnSMSClickListener? = null + + private val feedbackData = arrayOf( + arrayOf(R.drawable.ic_thumb_up_disable, R.drawable.ic_thumb_down), + arrayOf(R.drawable.ic_thumb_up_disable, R.drawable.ic_thumb_down_disable), + arrayOf(R.drawable.ic_thumb_up, R.drawable.ic_thumb_down_disable), + ) + + init { + mChatModelList = list + mContext = context + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val context = parent.context + val inflater = LayoutInflater.from(context) + + /** + * Inflate the custom layout and Return a new holder instance + */ + return if (viewType == 0) { + SendMessageViewHolder( + inflater.inflate( + R.layout.item_container_sent_message, parent, false + ) + ) + } else { + ReceiveMessageViewHolder( + inflater.inflate( + R.layout.item_container_received_message, parent, false + ) + ) + } + } + + /** + * Involves populating data into the item through holder + */ + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + /** + * Get the data model based on position + */ + val index = holder.adapterPosition + val messageModel: ChatMessageModel = mChatModelList[index] + if (messageModel.isMe) { + setSentMessageData(holder as SendMessageViewHolder, messageModel) + } else { + setReceiveMessageData(holder as ReceiveMessageViewHolder, messageModel) + } + } + + private fun setSentMessageData(holder: SendMessageViewHolder, messageModel: ChatMessageModel) { + /** + * Set item views based on your views and data model + */ + if (messageModel.message.isEmpty()) { + holder.textMessage.visibility = View.GONE + } else { + holder.textMessage.text = messageModel.message + holder.textMessage.visibility = View.VISIBLE + } + + + if (messageModel.image != null) { + val radius = mContext.resources.getDimensionPixelSize(R.dimen.chat_message_item_radius) + + val originBmp = BitmapFactory.decodeByteArray(messageModel.image, 0, messageModel.image!!.size) + val bmp = ImageHelper.getRoundedCornerBitmap(originBmp, radius) + holder.imgMessage.visibility = View.VISIBLE + holder.imgMessage.setImageBitmap(bmp) + holder.imgMessage.setOnClickListener { + onImageClick(originBmp) + } + } else { + holder.imgMessage.visibility = View.GONE + } + + if (messageModel.isWidget) { + when (messageModel.widgetType) { + MSG_WIDGET_TYPE_HELP_PRMOPT -> { + val model: HelpPromptModel = + HelpPromptModel.initModelWithString(messageModel.widgetDescription) + val helpPromptWidget = HelpPromptWidget(mContext, model) + val helpPromptListener = object : HelpPromptWidget.OnHelpPromptListener { + override fun onSuccess(prompt: String) { + mChatModelList[holder.adapterPosition].isWidget = false + holder.llMessageWidget.visibility = View.GONE + holder.llMessageWidget.removeAllViews() + mListener!!.sentHelpPrompt(prompt) + } + + override fun onCancel() { + mChatModelList[holder.adapterPosition].isWidget = false + holder.llMessageWidget.visibility = View.GONE + holder.llMessageWidget.removeAllViews() + mListener!!.canceledHelpPrompt() + } + } + helpPromptWidget.setOnClickListener(helpPromptListener) + holder.llMessageWidget.addView(helpPromptWidget) + holder.llMessageWidget.visibility = View.VISIBLE + } + } + } else { + holder.llMessageWidget.visibility = View.GONE + } + } + + @SuppressLint("UseCompatLoadingForDrawables") + fun setReceiveMessageData(holder: ReceiveMessageViewHolder, messageModel: ChatMessageModel) { + /** + * Set item views based on your views and data model + */ + if (messageModel.message.isEmpty()) { + holder.textMessage.visibility = View.GONE + } else { + holder.textMessage.text = messageModel.message + holder.textMessage.visibility = View.VISIBLE + } + + + if (messageModel.image != null) { + val radius = mContext.resources.getDimensionPixelSize(R.dimen.chat_message_item_radius) + + val originBmp = BitmapFactory.decodeByteArray(messageModel.image, 0, messageModel.image!!.size) + val bmp = ImageHelper.getRoundedCornerBitmap(originBmp, radius) + holder.imgMessage.visibility = View.VISIBLE + holder.imgMessage.setImageBitmap(bmp) + holder.imgMessage.setOnClickListener { + onImageClick(originBmp) + } + } else { + holder.imgMessage.visibility = View.GONE + } + + holder.llFeedback.visibility = if (messageModel.visibleFeedback) { + View.VISIBLE + } else { + View.GONE + } + + setThumb(holder) + + holder.itemLayout.setOnLongClickListener { + if (holder.llFeedback.visibility == View.VISIBLE) { + holder.llFeedback.visibility = View.GONE + mChatModelList[holder.adapterPosition].visibleFeedback = false + } else { + holder.llFeedback.visibility = View.VISIBLE + mChatModelList[holder.adapterPosition].visibleFeedback = true + } + return@setOnLongClickListener true + } + + holder.btnThumbUp.setOnClickListener { + mChatModelList[holder.adapterPosition].feedback = 1 + setThumb(holder) + + } + + holder.btnThumbDown.setOnClickListener { + mChatModelList[holder.adapterPosition].feedback = -1 + setThumb(holder) + } + + if (messageModel.isWidget) { + holder.llContactWidget.removeAllViews() + when (messageModel.widgetType) { + MSG_WIDGET_TYPE_SMS -> { + val smsWidget = SmsEditorWidget(mContext, null) + if(messageModel.widgetDescription.isNotEmpty()) { + smsWidget.setToUserName(messageModel.widgetDescription) + } + holder.llMessageWidget.addView(smsWidget) + holder.llMessageWidget.visibility = View.VISIBLE + + val smsListener = object : SmsEditorWidget.OnClickListener { + override fun confirmSMS(phonenumber: String, message: String) { + mChatModelList[holder.adapterPosition].isWidget = false + holder.llMessageWidget.visibility = View.GONE + holder.llMessageWidget.removeAllViews() + mListener!!.sentSMS(phonenumber, message) + } + + override fun cancelSMS() { + mChatModelList[holder.adapterPosition].isWidget = false + holder.llMessageWidget.visibility = View.GONE + holder.llMessageWidget.removeAllViews() + mListener!!.canceledSMS() + } + } + + smsWidget.setOnClickListener(smsListener) + } + + MSG_WIDGET_TYPE_SEARCH_CONTACT -> { + val contacts = Utils.instance.getContacts(mContext) + + val contactIds = JSONArray(messageModel.widgetDescription) + for (i in 0 until contactIds.length()) { + val contactId = contactIds[i].toString() + val contact = Utils.instance.getContactModelById(contactId, contacts) + + val searchContactWidget = SearchContactWidget(mContext, contact, null) + searchContactWidget.mSMSOnClickListener = mOnSMSClickListener + holder.llContactWidget.addView(searchContactWidget) + } + holder.llContactWidget.visibility = View.VISIBLE + } + } + } else { + holder.llMessageWidget.visibility = View.GONE + holder.llContactWidget.visibility = View.GONE + } + } + + @SuppressLint("UseCompatLoadingForDrawables") + fun setThumb(holder: ReceiveMessageViewHolder) { + holder.btnThumbUp.setImageDrawable( + mContext.getDrawable( + feedbackData[mChatModelList[holder.adapterPosition].feedback + 1][0] + ) + ) + holder.btnThumbDown.setImageDrawable( + mContext.getDrawable( + feedbackData[mChatModelList[holder.adapterPosition].feedback + 1][1] + ) + ) + } + + /** + * Returns the total count of items in the list + */ + override fun getItemCount(): Int { + return mChatModelList.size + } + + override fun getItemViewType(position: Int): Int { + return if (mChatModelList[position].isMe) 0 else 1 + } + + private fun onImageClick(bitmap: Bitmap) { + val dialog = Dialog(mContext) + dialog.setContentView(R.layout.view_full_image) + val fullImage = dialog.findViewById(R.id.fullImage) as ImageView + fullImage.setImageBitmap(bitmap) + dialog.show() + } + + inner class ReceiveMessageViewHolder internal constructor(itemView: View) : + RecyclerView.ViewHolder(itemView) { + var textMessage: TextView + var imgMessage: ImageView + var llFeedback: LinearLayout + var btnThumbUp: ImageView + var btnThumbDown: ImageView + var itemLayout: ConstraintLayout + var llMessageWidget: LinearLayout + var llContactWidget: LinearLayout + + init { + textMessage = itemView.findViewById(R.id.textMessage) as TextView + imgMessage = itemView.findViewById(R.id.imgMessage) as ImageView + btnThumbUp = itemView.findViewById(R.id.btn_thumb_up) as ImageView + btnThumbDown = itemView.findViewById(R.id.btn_thumb_down) as ImageView + llFeedback = itemView.findViewById(R.id.ll_feedback) as LinearLayout + itemLayout = itemView.findViewById(R.id.cl_receive_message) as ConstraintLayout + llMessageWidget = itemView.findViewById(R.id.ll_message_widget) as LinearLayout + llContactWidget = itemView.findViewById(R.id.ll_contacts_widget) as LinearLayout + } + } + + inner class SendMessageViewHolder internal constructor(itemView: View) : + RecyclerView.ViewHolder(itemView) { + var textMessage: TextView + var imgMessage: ImageView + var itemLayout: ConstraintLayout + var llMessageWidget: LinearLayout + + init { + textMessage = itemView.findViewById(R.id.textMessage) as TextView + imgMessage = itemView.findViewById(R.id.imgMessage) as ImageView + itemLayout = itemView.findViewById(R.id.cl_sent_message) as ConstraintLayout + llMessageWidget = itemView.findViewById(R.id.ll_message_widget) as LinearLayout + } + } + + interface MessageWidgetListener { + fun sentSMS(phonenumber: String, message: String) + fun canceledSMS() + fun sentHelpPrompt(prompt: String) + fun canceledHelpPrompt() + } + + fun setMessageWidgetListener(listener: MessageWidgetListener) { + mListener = listener + } +} \ No newline at end of file diff --git a/Android/app/src/main/java/com/matthaigh27/chatgptwrapper/database/MyDatabase.kt b/Android/app/src/main/java/com/matthaigh27/chatgptwrapper/database/MyDatabase.kt new file mode 100644 index 0000000000000000000000000000000000000000..24767388367000054442350471abf4c4cfb497e1 --- /dev/null +++ b/Android/app/src/main/java/com/matthaigh27/chatgptwrapper/database/MyDatabase.kt @@ -0,0 +1,38 @@ +package com.matthaigh27.chatgptwrapper.database + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import com.matthaigh27.chatgptwrapper.database.dao.ContactDao +import com.matthaigh27.chatgptwrapper.database.dao.ImageDao +import com.matthaigh27.chatgptwrapper.database.entity.ContactEntity +import com.matthaigh27.chatgptwrapper.database.entity.ImageEntity + +@Database(entities = [ImageEntity::class, ContactEntity::class], version = 1, exportSchema = false) +abstract class MyDatabase : RoomDatabase() { + + abstract fun imageDao(): ImageDao + abstract fun contactDao(): ContactDao + + companion object { + @Volatile + private var INSTANCE: MyDatabase? = null + + fun getDatabase(context: Context): MyDatabase { + val tempInstance = INSTANCE + if (tempInstance != null) { + return tempInstance + } + synchronized(this) { + val instance = Room.databaseBuilder( + context.applicationContext, + MyDatabase::class.java, + "risingphone_database" + ).build() + INSTANCE = instance + return instance + } + } + } +} \ No newline at end of file diff --git a/Android/app/src/main/java/com/matthaigh27/chatgptwrapper/database/dao/ContactDao.kt b/Android/app/src/main/java/com/matthaigh27/chatgptwrapper/database/dao/ContactDao.kt new file mode 100644 index 0000000000000000000000000000000000000000..53d3f5ab8edaf727e24dfff3e382753e152b62d1 --- /dev/null +++ b/Android/app/src/main/java/com/matthaigh27/chatgptwrapper/database/dao/ContactDao.kt @@ -0,0 +1,20 @@ +package com.matthaigh27.chatgptwrapper.database.dao + +import androidx.lifecycle.LiveData +import androidx.room.* +import com.matthaigh27.chatgptwrapper.database.entity.ContactEntity + +@Dao +interface ContactDao { + @Insert + suspend fun insertContact(contact: ContactEntity) + + @Update + suspend fun updateContact(contact: ContactEntity) + + @Delete + suspend fun deleteContact(contact: ContactEntity) + + @Query("SELECT * FROM contacts") + fun getAllContacts(): List +} \ No newline at end of file diff --git a/Android/app/src/main/java/com/matthaigh27/chatgptwrapper/database/dao/ImageDao.kt b/Android/app/src/main/java/com/matthaigh27/chatgptwrapper/database/dao/ImageDao.kt new file mode 100644 index 0000000000000000000000000000000000000000..eb20e1682256bf245ba19178ae4090d554ccb7b5 --- /dev/null +++ b/Android/app/src/main/java/com/matthaigh27/chatgptwrapper/database/dao/ImageDao.kt @@ -0,0 +1,20 @@ +package com.matthaigh27.chatgptwrapper.database.dao + +import androidx.lifecycle.LiveData +import androidx.room.* +import com.matthaigh27.chatgptwrapper.database.entity.ImageEntity + +@Dao +interface ImageDao { + @Insert + suspend fun insertImage(image: ImageEntity) + + @Update + suspend fun updateImage(image: ImageEntity) + + @Delete + suspend fun deleteImage(image: ImageEntity) + + @Query("SELECT * FROM images") + fun getAllImages(): List +} \ No newline at end of file diff --git a/Android/app/src/main/java/com/matthaigh27/chatgptwrapper/database/entity/ContactEntity.kt b/Android/app/src/main/java/com/matthaigh27/chatgptwrapper/database/entity/ContactEntity.kt new file mode 100644 index 0000000000000000000000000000000000000000..be6009712233de4e7746f62d19a3ae5c0ed20a98 --- /dev/null +++ b/Android/app/src/main/java/com/matthaigh27/chatgptwrapper/database/entity/ContactEntity.kt @@ -0,0 +1,11 @@ +package com.matthaigh27.chatgptwrapper.database.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "contacts") +data class ContactEntity ( + @PrimaryKey(autoGenerate = false) val id: String, + val name: String, + val phoneNumber: String, +) \ No newline at end of file diff --git a/Android/app/src/main/java/com/matthaigh27/chatgptwrapper/database/entity/ImageEntity.kt b/Android/app/src/main/java/com/matthaigh27/chatgptwrapper/database/entity/ImageEntity.kt new file mode 100644 index 0000000000000000000000000000000000000000..8734569a0faf073e90669393fb2cd5c83a6c0026 --- /dev/null +++ b/Android/app/src/main/java/com/matthaigh27/chatgptwrapper/database/entity/ImageEntity.kt @@ -0,0 +1,11 @@ +package com.matthaigh27.chatgptwrapper.database.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "images") +data class ImageEntity ( + @PrimaryKey(autoGenerate = true) val id: Int, + val path: String, + val name: String, +) \ No newline at end of file diff --git a/Android/app/src/main/java/com/matthaigh27/chatgptwrapper/dialogs/CommonConfirmDialog.kt b/Android/app/src/main/java/com/matthaigh27/chatgptwrapper/dialogs/CommonConfirmDialog.kt new file mode 100644 index 0000000000000000000000000000000000000000..5cea7908d29de7db5d74c6aeeaf97c84dddb558e --- /dev/null +++ b/Android/app/src/main/java/com/matthaigh27/chatgptwrapper/dialogs/CommonConfirmDialog.kt @@ -0,0 +1,74 @@ +package com.matthaigh27.chatgptwrapper.dialogs + +import android.app.Dialog +import android.content.Context +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.os.Bundle +import android.view.View +import android.view.ViewGroup +import android.view.Window +import android.widget.Button +import android.widget.TextView +import com.matthaigh27.chatgptwrapper.R + +class CommonConfirmDialog(context: Context) : Dialog(context), View.OnClickListener { + + private var mTvMessage: TextView? = null + private var mMessage: String = "" + private lateinit var mClickListener: OnConfirmButtonClickListener + + init { + setCancelable(false) + } + + fun setOnClickListener(listener: OnConfirmButtonClickListener) { + mClickListener = listener + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + initView() + } + + private fun initView() { + requestWindowFeature(Window.FEATURE_NO_TITLE) + setContentView(R.layout.dialog_common_confirm) + + window!!.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) + window!!.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + + findViewById + + + ) +} + +export default ConfirmButton diff --git a/Extension/src/components/ConversationCard/index.jsx b/Extension/src/components/ConversationCard/index.jsx new file mode 100644 index 0000000000000000000000000000000000000000..2852e32f586b4522eb030ab45d151f708221c4bf --- /dev/null +++ b/Extension/src/components/ConversationCard/index.jsx @@ -0,0 +1,483 @@ +import { memo, useEffect, useRef, useState } from 'react' +import PropTypes from 'prop-types' +import Browser from 'webextension-polyfill' +import InputBox from '../InputBox' +import ConversationItem from '../ConversationItem' +import { createElementAtPosition } from '../../utils' +import { DownloadIcon, LinkExternalIcon, ArchiveIcon } from '@primer/octicons-react' +import { WindowDesktop, XLg, Pin } from 'react-bootstrap-icons' +import FileSaver from 'file-saver' +import { render } from 'preact' +import FloatingToolbar from '../FloatingToolbar' +import { useClampWindowSize } from '../../hooks/use-clamp-window-size' +import { ModelMode, Models } from '../../config/index.mjs' +import { useTranslation } from 'react-i18next' +import DeleteButton from '../DeleteButton' +import { useConfig } from '../../hooks/use-config.mjs' +import { createSession } from '../../services/local-session.mjs' +import { v4 as uuidv4 } from 'uuid' +import { initSession } from '../../services/init-session.mjs' +import { findLastIndex } from 'lodash-es' + +const logo = Browser.runtime.getURL('logo1.png') + +class ConversationItemData extends Object { + /** + * @param {'question'|'answer'|'error'} type + * @param {string} content + * @param {bool} done + */ + constructor(type, content, done = false) { + super() + this.type = type + this.content = content + this.done = done + } +} + +function ConversationCard(props) { + const { t } = useTranslation() + const [isReady, setIsReady] = useState(!props.question) + const [port, setPort] = useState(() => Browser.runtime.connect()) + const [session, setSession] = useState(props.session) + const windowSize = useClampWindowSize([750, 1500], [250, 1100]) + const bodyRef = useRef(null) + + /** + * @type {[ConversationItemData[], (conversationItemData: ConversationItemData[]) => void]} + */ + const [conversationItemData, setConversationItemData] = useState( + (() => { + if (session.conversationRecords.length === 0) + if (props.question) + return [ + new ConversationItemData( + 'answer', + `

${t(`Waiting for response...`)}

`, + ), + ] + else return [] + else { + const ret = [] + for (const record of session.conversationRecords) { + ret.push(new ConversationItemData('question', record.question, true)) + ret.push(new ConversationItemData('answer', record.answer, true)) + } + return ret + } + })(), + ) + const config = useConfig() + + useEffect(() => { + if (props.onUpdate) props.onUpdate(port, session, conversationItemData) + }, [session, conversationItemData]) + + useEffect(() => { + bodyRef.current.scrollTop = bodyRef.current.scrollHeight + }, [session]) + + useEffect(() => { + if (config.lockWhenAnswer) bodyRef.current.scrollTop = bodyRef.current.scrollHeight + }, [conversationItemData]) + + useEffect(() => { + // when the page is responsive, session may accumulate redundant data and needs to be cleared after remounting and before making a new request + if (props.question) { + const newSession = initSession({ question: props.question }) + setSession(newSession) + port.postMessage({ session: newSession }) + } + }, [props.question]) // usually only triggered once + + /** + * @param {string} value + * @param {boolean} appended + * @param {'question'|'answer'|'error'} newType + * @param {boolean} done + */ + const updateAnswer = (value, appended, newType, done = false) => { + setConversationItemData((old) => { + const copy = [...old] + const index = findLastIndex(copy, (v) => v.type === 'answer' || v.type === 'error') + if (index === -1) return copy + copy[index] = new ConversationItemData( + newType, + appended ? copy[index].content + value : value, + ) + copy[index].done = done + return copy + }) + } + + useEffect(() => { + const portListener = () => { + setPort(Browser.runtime.connect()) + setIsReady(true) + } + + const closeChatsListener = (message) => { + if (message.type === 'CLOSE_CHATS') { + port.disconnect() + if (props.onClose) props.onClose() + } + } + + if (props.closeable) Browser.runtime.onMessage.addListener(closeChatsListener) + port.onDisconnect.addListener(portListener) + return () => { + if (props.closeable) Browser.runtime.onMessage.removeListener(closeChatsListener) + port.onDisconnect.removeListener(portListener) + } + }, [port]) + + useEffect(() => { + const listener = (msg) => { + let answer = {} + if (msg.answer) answer = JSON.parse(msg.answer?.replace(/'/g, '"')) + + if (answer?.program) { + /** + * Tab and Page Manage - Open, Search, Close, Next/Previous Page, Scroll + */ + const search = answer?.content + let page = 0 + const currentUrl = window.location.href + + switch (answer.program) { + case 'open_tab': + window.open('https://google.com/search?q=', '_blank', 'noreferrer') + break + case 'open_tab_search': + window.open('https://google.com/search?q=' + search, '_blank', 'noreferrer') + break + case 'close_tab': + //window.focus() + window.close() + break + case 'previous_page': + if (page === 0) { + page = 0 + } else { + page -= 10 + } + + window.location.assign(currentUrl + '&start=' + page) + break + case 'next_page': + page += 10 + + window.location.assign(currentUrl + '&start=' + page) + break + case 'scroll_up': + window.scrollBy(0, -300) + break + case 'scroll_down': + window.scrollBy(0, 300) + break + case 'scroll_top': + window.scrollTo(0, 0) + break + case 'scroll_bottom': + window.scrollTo(0, document.body.scrollHeight) + break + default: + break + } + + updateAnswer(answer.program, false, 'answer') + } + + if (msg.session) { + if (msg.done) msg.session = { ...msg.session, isRetry: false } + setSession(msg.session) + } + + if (msg.done) { + updateAnswer('', true, 'answer', true) + setIsReady(true) + } + + if (msg.error) { + switch (msg.error) { + case 'UNAUTHORIZED': + updateAnswer( + `${t('UNAUTHORIZED')}
${t('Please login at https://chat.openai.com first')} +
${t('And refresh this page or type you question again')}` + + `

${t( + 'Consider creating an api key at https://platform.openai.com/account/api-keys', + )}`, + false, + 'error', + ) + break + case 'CLOUDFLARE': + updateAnswer( + `${t('OpenAI Security Check Required')}

${t( + 'And refresh this page or type you question again', + )}` + + `

${t( + 'Consider creating an api key at https://platform.openai.com/account/api-keys', + )}`, + false, + 'error', + ) + break + default: + if ( + conversationItemData[conversationItemData.length - 1].content.includes('gpt-loading') + ) + updateAnswer(msg.error, false, 'error') + else + setConversationItemData([ + ...conversationItemData, + new ConversationItemData('error', msg.error), + ]) + break + } + setIsReady(true) + } + } + port.onMessage.addListener(listener) + return () => { + port.onMessage.removeListener(listener) + } + }, [conversationItemData]) + + const getRetryFn = (session) => () => { + updateAnswer(`

${t('Waiting for response...')}

`, false, 'answer') + setIsReady(false) + + const newSession = { ...session, isRetry: true } + setSession(newSession) + try { + port.postMessage({ stop: true }) + port.postMessage({ session: newSession }) + } catch (e) { + updateAnswer(e, false, 'error') + } + } + + return ( +
+
+ + {props.closeable ? ( + { + port.disconnect() + if (props.onClose) props.onClose() + }} + /> + ) : props.dockable ? ( + { + if (props.onDock) props.onDock() + }} + /> + ) : ( + no image + )} + + + + {session && session.conversationId && ( + + + + )} + { + const position = { x: window.innerWidth / 2 - 300, y: window.innerHeight / 2 - 200 } + const toolbarContainer = createElementAtPosition(position.x, position.y) + toolbarContainer.className = 'chatgptbox-toolbar-container-not-queryable' + render( + , + toolbarContainer, + ) + }} + /> + { + port.postMessage({ stop: true }) + Browser.runtime.sendMessage({ + type: 'DELETE_CONVERSATION', + data: { + conversationId: session.conversationId, + }, + }) + setConversationItemData([]) + const newSession = initSession({ + ...session, + question: null, + conversationRecords: [], + }) + newSession.sessionId = session.sessionId + setSession(newSession) + }} + /> + {!props.pageMode && ( + { + const newSession = { + ...session, + sessionName: new Date().toLocaleString(), + autoClean: false, + sessionId: uuidv4(), + } + setSession(newSession) + createSession(newSession).then(() => + Browser.runtime.sendMessage({ + type: 'OPEN_URL', + data: { + url: Browser.runtime.getURL('IndependentPanel.html'), + }, + }), + ) + }} + > + + + )} + { + let output = '' + session.conversationRecords.forEach((data) => { + output += `${t('Question')}:\n\n${data.question}\n\n${t('Answer')}:\n\n${ + data.answer + }\n\n
\n\n` + }) + const blob = new Blob([output], { type: 'text/plain;charset=utf-8' }) + FileSaver.saveAs(blob, 'conversation.md') + }} + > + +
+ +
+
+
+ {conversationItemData.map((data, idx) => ( + + ))} +
+ { + console.log('new quetion------------->', question) + const newQuestion = new ConversationItemData('question', question) + const newAnswer = new ConversationItemData( + 'answer', + `

${t('Waiting for response...')}

`, + ) + setConversationItemData([...conversationItemData, newQuestion, newAnswer]) + setIsReady(false) + + const newSession = { ...session, question, isRetry: false } + setSession(newSession) + try { + port.postMessage({ session: newSession }) + } catch (e) { + updateAnswer(e, false, 'error') + } + }} + /> +
+ ) +} + +ConversationCard.propTypes = { + session: PropTypes.object.isRequired, + question: PropTypes.string.isRequired, + onUpdate: PropTypes.func, + draggable: PropTypes.bool, + closeable: PropTypes.bool, + onClose: PropTypes.func, + dockable: PropTypes.bool, + onDock: PropTypes.func, + notClampSize: PropTypes.bool, + pageMode: PropTypes.bool, +} + +export default memo(ConversationCard) diff --git a/Extension/src/components/ConversationItem/index.jsx b/Extension/src/components/ConversationItem/index.jsx new file mode 100644 index 0000000000000000000000000000000000000000..f74c83a73cbe7645c583850f12af59724f585c89 --- /dev/null +++ b/Extension/src/components/ConversationItem/index.jsx @@ -0,0 +1,140 @@ +import { useState } from 'react' +import { ChevronDownIcon, XCircleIcon, SyncIcon } from '@primer/octicons-react' +import CopyButton from '../CopyButton' +import ReadButton from '../ReadButton' +import PropTypes from 'prop-types' +import MarkdownRender from '../MarkdownRender/markdown.jsx' +import { useTranslation } from 'react-i18next' + +export function ConversationItem({ type, content, session, done, port, onRetry }) { + const { t } = useTranslation() + const [collapsed, setCollapsed] = useState(false) + + switch (type) { + case 'question': + return ( +
+
+

{t('You')}:

+
+ content.replace(/\n$/, '')} size={14} /> + content} size={14} /> + {!collapsed ? ( + setCollapsed(true)} + > + + + ) : ( + setCollapsed(false)} + > + + + )} +
+
+ {!collapsed && {content}} +
+ ) + case 'answer': + return ( +
+
+

+ {session && session.aiName ? `${t(session.aiName)}` : t('Loading...')} +

+
+ {!done && ( + + )} + {onRetry && ( + + + + )} + {session && ( + content.replace(/\n$/, '')} size={14} /> + )} + {session && content} size={14} />} + {!collapsed ? ( + setCollapsed(true)} + > + + + ) : ( + setCollapsed(false)} + > + + + )} +
+
+ {!collapsed && {content}} +
+ ) + case 'error': + return ( +
+
+

{t('Error')}:

+
+ {onRetry && ( + + + + )} + content.replace(/\n$/, '')} size={14} /> + {!collapsed ? ( + setCollapsed(true)} + > + + + ) : ( + setCollapsed(false)} + > + + + )} +
+
+ {!collapsed && {content}} +
+ ) + } +} + +ConversationItem.propTypes = { + type: PropTypes.oneOf(['question', 'answer', 'error']).isRequired, + content: PropTypes.string.isRequired, + session: PropTypes.object.isRequired, + done: PropTypes.bool.isRequired, + port: PropTypes.object.isRequired, + onRetry: PropTypes.func, +} + +export default ConversationItem diff --git a/Extension/src/components/CopyButton/index.jsx b/Extension/src/components/CopyButton/index.jsx new file mode 100644 index 0000000000000000000000000000000000000000..4e4dc0a0f26ec8f1dfa6d87ed527f767d9453470 --- /dev/null +++ b/Extension/src/components/CopyButton/index.jsx @@ -0,0 +1,38 @@ +import { useState } from 'react' +import { CheckIcon, CopyIcon } from '@primer/octicons-react' +import PropTypes from 'prop-types' +import { useTranslation } from 'react-i18next' + +CopyButton.propTypes = { + contentFn: PropTypes.func.isRequired, + size: PropTypes.number.isRequired, + className: PropTypes.string, +} + +function CopyButton({ className, contentFn, size }) { + const { t } = useTranslation() + const [copied, setCopied] = useState(false) + + const onClick = () => { + navigator.clipboard + .writeText(contentFn()) + .then(() => setCopied(true)) + .then(() => + setTimeout(() => { + setCopied(false) + }, 600), + ) + } + + return ( + + {copied ? : } + + ) +} + +export default CopyButton diff --git a/Extension/src/components/DecisionCard/index.jsx b/Extension/src/components/DecisionCard/index.jsx new file mode 100644 index 0000000000000000000000000000000000000000..2b2950ac67b59009a597462fbaf019157628fd25 --- /dev/null +++ b/Extension/src/components/DecisionCard/index.jsx @@ -0,0 +1,125 @@ +import { LightBulbIcon, SearchIcon } from '@primer/octicons-react' +import { useState, useEffect } from 'react' +import PropTypes from 'prop-types' +import ConversationCard from '../ConversationCard' +import { getPossibleElementByQuerySelector, endsWithQuestionMark } from '../../utils' +import { useTranslation } from 'react-i18next' +import { useConfig } from '../../hooks/use-config.mjs' + +function DecisionCard(props) { + const { t } = useTranslation() + const [triggered, setTriggered] = useState(false) + const [render, setRender] = useState(false) + const config = useConfig(() => { + setRender(true) + }) + + const question = props.question + + const updatePosition = () => { + if (!render) return + + const container = props.container + const siteConfig = props.siteConfig + container.classList.remove('chatgptbox-sidebar-free') + + if (config.appendQuery) { + const appendContainer = getPossibleElementByQuerySelector([config.appendQuery]) + if (appendContainer) { + appendContainer.appendChild(container) + return + } + } + + if (config.prependQuery) { + const prependContainer = getPossibleElementByQuerySelector([config.prependQuery]) + if (prependContainer) { + prependContainer.prepend(container) + return + } + } + + if (!siteConfig) return + + if (config.insertAtTop) { + const resultsContainerQuery = getPossibleElementByQuerySelector( + siteConfig.resultsContainerQuery, + ) + if (resultsContainerQuery) resultsContainerQuery.prepend(container) + } else { + const sidebarContainer = getPossibleElementByQuerySelector(siteConfig.sidebarContainerQuery) + if (sidebarContainer) { + sidebarContainer.prepend(container) + } else { + const appendContainer = getPossibleElementByQuerySelector(siteConfig.appendContainerQuery) + if (appendContainer) { + container.classList.add('chatgptbox-sidebar-free') + appendContainer.appendChild(container) + } else { + const resultsContainerQuery = getPossibleElementByQuerySelector( + siteConfig.resultsContainerQuery, + ) + if (resultsContainerQuery) resultsContainerQuery.prepend(container) + } + } + } + } + + useEffect(() => updatePosition(), [config]) + + return ( + render && ( +
+ {(() => { + if (question) + switch (config.triggerMode) { + case 'always': + return + case 'manually': + if (triggered) { + return + } + return ( +

setTriggered(true)}> + + {t('Ask RisingBrowser')} + +

+ ) + case 'questionMark': + if (endsWithQuestionMark(question.trim())) { + return + } + if (triggered) { + return + } + return ( +

setTriggered(true)}> + + {t('Ask RisingBrowser')} + +

+ ) + } + else + return ( +

+ + {t('No Input Found')} + +

+ ) + })()} +
+ ) + ) +} + +DecisionCard.propTypes = { + session: PropTypes.object.isRequired, + question: PropTypes.string.isRequired, + siteConfig: PropTypes.object.isRequired, + container: PropTypes.object.isRequired, +} + +export default DecisionCard diff --git a/Extension/src/components/DeleteButton/index.jsx b/Extension/src/components/DeleteButton/index.jsx new file mode 100644 index 0000000000000000000000000000000000000000..ad5f5b7604a1ac7ddae12bb4e734d4719f50dbb3 --- /dev/null +++ b/Extension/src/components/DeleteButton/index.jsx @@ -0,0 +1,59 @@ +import { useEffect, useRef, useState } from 'react' +import PropTypes from 'prop-types' +import { useTranslation } from 'react-i18next' +import { TrashIcon } from '@primer/octicons-react' + +DeleteButton.propTypes = { + onConfirm: PropTypes.func.isRequired, + size: PropTypes.number.isRequired, + text: PropTypes.string.isRequired, +} + +function DeleteButton({ onConfirm, size, text }) { + const { t } = useTranslation() + const [waitConfirm, setWaitConfirm] = useState(false) + const confirmRef = useRef(null) + + useEffect(() => { + if (waitConfirm) confirmRef.current.focus() + }, [waitConfirm]) + + return ( + + + { + setWaitConfirm(true) + }} + > + + + + ) +} + +export default DeleteButton diff --git a/Extension/src/components/FeedbackForChatGPTWeb/index.jsx b/Extension/src/components/FeedbackForChatGPTWeb/index.jsx new file mode 100644 index 0000000000000000000000000000000000000000..2a9d19d299e585ff03acf9ddcf582473ef31cc1b --- /dev/null +++ b/Extension/src/components/FeedbackForChatGPTWeb/index.jsx @@ -0,0 +1,68 @@ +import PropTypes from 'prop-types' +import { memo, useCallback, useState } from 'react' +import { ThumbsupIcon, ThumbsdownIcon } from '@primer/octicons-react' +import Browser from 'webextension-polyfill' +import { useTranslation } from 'react-i18next' + +const FeedbackForChatGPTWeb = (props) => { + const { t } = useTranslation() + const [action, setAction] = useState(null) + + const clickThumbsUp = useCallback(async () => { + if (action) { + return + } + setAction('thumbsUp') + await Browser.runtime.sendMessage({ + type: 'FEEDBACK', + data: { + conversation_id: props.conversationId, + message_id: props.messageId, + rating: 'thumbsUp', + }, + }) + }, [props, action]) + + const clickThumbsDown = useCallback(async () => { + if (action) { + return + } + setAction('thumbsDown') + await Browser.runtime.sendMessage({ + type: 'FEEDBACK', + data: { + conversation_id: props.conversationId, + message_id: props.messageId, + rating: 'thumbsDown', + text: '', + tags: [], + }, + }) + }, [props, action]) + + return ( +
+ + + + + + +
+ ) +} + +FeedbackForChatGPTWeb.propTypes = { + messageId: PropTypes.string.isRequired, + conversationId: PropTypes.string.isRequired, +} + +export default memo(FeedbackForChatGPTWeb) diff --git a/Extension/src/components/FloatingToolbar/index.jsx b/Extension/src/components/FloatingToolbar/index.jsx new file mode 100644 index 0000000000000000000000000000000000000000..7fa015fa9ca2119952c875b3a27a17694a0d66b0 --- /dev/null +++ b/Extension/src/components/FloatingToolbar/index.jsx @@ -0,0 +1,103 @@ +import ConversationCard from '../ConversationCard' +import { useState } from 'react' +import PropTypes from 'prop-types' +import { getClientPosition, setElementPositionInViewport } from '../../utils' +import Draggable from 'react-draggable' +import { useClampWindowSize } from '../../hooks/use-clamp-window-size' +import { useConfig } from '../../hooks/use-config.mjs' + +function FloatingToolbar(props) { + const [render, setRender] = useState(false) + const [closeable, setCloseable] = useState(props.closeable) + const [position, setPosition] = useState(getClientPosition(props.container)) + const [virtualPosition, setVirtualPosition] = useState({ x: 0, y: 0 }) + const windowSize = useClampWindowSize([750, 1500], [0, Infinity]) + const config = useConfig(() => { + setRender(true) + if (!props.triggered) { + props.container.style.position = 'absolute' + setTimeout(() => { + const left = Math.min( + Math.max(0, window.innerWidth - props.container.offsetWidth - 30), + Math.max(0, position.x), + ) + props.container.style.left = left + 'px' + }) + } + }) + + if (!render) return
+ + if (props.triggered) { + const updatePosition = () => { + const newPosition = setElementPositionInViewport(props.container, position.x, position.y) + if (position.x !== newPosition.x || position.y !== newPosition.y) setPosition(newPosition) // clear extra virtual position offset + } + + const dragEvent = { + onDrag: (e, ui) => { + setVirtualPosition({ x: virtualPosition.x + ui.deltaX, y: virtualPosition.y + ui.deltaY }) + }, + onStop: () => { + setPosition({ x: position.x + virtualPosition.x, y: position.y + virtualPosition.y }) + setVirtualPosition({ x: 0, y: 0 }) + }, + } + + if (virtualPosition.x === 0 && virtualPosition.y === 0) { + updatePosition() // avoid jitter + } + + const onDock = () => { + props.container.className = 'chatgptbox-toolbar-container-not-queryable' + setCloseable(true) + } + + if (config.alwaysPinWindow) onDock() + + return ( +
+ +
+
+ { + props.container.remove() + }} + dockable={props.dockable} + onDock={onDock} + onUpdate={() => { + updatePosition() + }} + /> +
+
+
+
+ ) + } +} + +FloatingToolbar.propTypes = { + session: PropTypes.object.isRequired, + selection: PropTypes.string.isRequired, + container: PropTypes.object.isRequired, + triggered: PropTypes.bool, + closeable: PropTypes.bool, + dockable: PropTypes.bool, + prompt: PropTypes.string, +} + +export default FloatingToolbar diff --git a/Extension/src/components/InputBox/index.jsx b/Extension/src/components/InputBox/index.jsx new file mode 100644 index 0000000000000000000000000000000000000000..748b84cb910b877db15e799f1de5ade11fd95834 --- /dev/null +++ b/Extension/src/components/InputBox/index.jsx @@ -0,0 +1,117 @@ +import { useEffect, useRef, useState } from 'react' +import PropTypes from 'prop-types' +import { updateRefHeight } from '../../utils' +import { useTranslation } from 'react-i18next' +import { getUserConfig } from '../../config/index.mjs' + +export function InputBox({ onSubmit, enabled, port, reverseResizeDir }) { + const { t } = useTranslation() + const [value, setValue] = useState('') + const reverseDivRef = useRef(null) + const inputRef = useRef(null) + const resizedRef = useRef(false) + + const virtualInputRef = reverseResizeDir ? reverseDivRef : inputRef + + useEffect(() => { + inputRef.current.focus() + + const onResizeY = () => { + if (virtualInputRef.current.h !== virtualInputRef.current.offsetHeight) { + virtualInputRef.current.h = virtualInputRef.current.offsetHeight + if (!resizedRef.current) { + resizedRef.current = true + virtualInputRef.current.style.maxHeight = '' + } + } + } + virtualInputRef.current.h = virtualInputRef.current.offsetHeight + virtualInputRef.current.addEventListener('mousemove', onResizeY) + }, []) + + useEffect(() => { + if (!resizedRef.current) { + if (!reverseResizeDir) { + updateRefHeight(inputRef) + virtualInputRef.current.h = virtualInputRef.current.offsetHeight + virtualInputRef.current.style.maxHeight = '160px' + } + } + }) + + useEffect(() => { + if (enabled) + getUserConfig().then((config) => { + if (config.focusAfterAnswer) inputRef.current.focus() + }) + }, [enabled]) + + const handleKeyDownOrClick = (e) => { + e.stopPropagation() + if (e.type === 'click' || (e.keyCode === 13 && e.shiftKey === false)) { + if (enabled) { + e.preventDefault() + if (!value) return + onSubmit(value) + setValue('') + } else { + e.preventDefault() + port.postMessage({ stop: true }) + } + } + } + + return ( +
+
+