From 1a286e9f69109ed8674e36d094ffe0559309a85d Mon Sep 17 00:00:00 2001 From: leo Date: Tue, 14 Mar 2023 20:35:02 +0900 Subject: [PATCH] Added 3.5.0 --- CHANGELOG.md | 5 + build.gradle | 4 + gradle.properties | 2 +- .../src/main/res/values-ko-rKR/strings.xml | 1 + uikit/build.gradle | 4 +- uikit/consumer-rules.pro | 48 +- uikit/src/main/AndroidManifest.xml | 10 + .../com/sendbird/uikit/SendbirdUIKit.java | 32 +- .../ChatNotificationChannelActivity.java | 84 ++++ .../FeedNotificationChannelActivity.java | 84 ++++ .../adapter/BaseMessageAdapter.java | 6 +- .../adapter/ChannelListAdapter.java | 8 +- .../adapter/MessageListAdapter.java | 1 - .../activities/viewholder/MessageType.java | 12 + .../com/sendbird/uikit/consts/StringSet.java | 7 +- .../uikit/fragments/ChannelFragment.java | 8 +- .../uikit/fragments/ChannelListFragment.java | 12 +- .../ChatNotificationChannelFragment.java | 394 +++++++++++++++ .../FeedNotificationChannelFragment.java | 407 ++++++++++++++++ .../uikit/fragments/UIKitFragmentFactory.java | 28 ++ .../OnNotificationTemplateActionHandler.java | 25 + .../internal/extensions/MessageExtensions.kt | 2 +- .../uikit/internal/extensions/Resources.kt | 21 + .../uikit/internal/extensions/Utils.kt | 12 + .../internal/extensions/ViewExtensions.kt | 81 +++- .../uikit/internal/interfaces/Disposable.kt | 5 + .../internal/interfaces/ViewRoundable.kt | 10 + .../internal/model/ExtendedMessageType.kt | 36 ++ .../model/NotificationDiffCallback.kt | 85 ++++ .../uikit/internal/model/VoicePlayer.kt | 7 +- .../notifications/NotificationChannelTheme.kt | 95 ++++ .../model/notifications/NotificationCommon.kt | 53 ++ .../model/notifications/NotificationConfig.kt | 22 + .../notifications/NotificationTemplate.kt | 73 +++ .../internal/model/serializer/Serializers.kt | 55 +++ .../internal/model/template_messages/Enums.kt | 117 +++++ .../model/template_messages/KeySet.kt | 78 +++ .../model/template_messages/Params.kt | 234 +++++++++ .../model/template_messages/Styles.kt | 118 +++++ .../model/template_messages/Template.kt | 175 +++++++ .../TemplateViewGenerator.kt | 126 +++++ .../singleton/BaseSharedPreference.kt | 49 ++ .../singleton/MessageTemplateParser.kt | 145 ++++++ .../singleton/NotificationChannelManager.kt | 104 ++++ .../NotificationChannelRepository.kt | 84 ++++ .../internal/singleton/NotificationParser.kt | 30 ++ .../NotificationTemplateRepository.kt | 129 +++++ .../internal/ui/channels/ChannelPreview.kt | 37 +- .../internal/ui/components/HeaderView.kt | 9 + .../ui/messages/ChatNotificationView.kt | 149 ++++++ .../ui/messages/FeedNotificationView.kt | 164 +++++++ .../ui/messages/TimelineMessageView.kt | 19 + .../internal/ui/messages/VoiceMessageView.kt | 1 - .../ChatNotificationChannelModule.kt | 178 +++++++ .../ChatNotificationHeaderComponent.kt | 90 ++++ .../ChatNotificationListAdapter.kt | 171 +++++++ .../ChatNotificationListComponent.kt | 109 +++++ .../FeedNotificationChannelModule.kt | 173 +++++++ .../FeedNotificationHeaderComponent.kt | 88 ++++ .../FeedNotificationListAdapter.kt | 179 +++++++ .../FeedNotificationListComponent.kt | 126 +++++ .../NotificationListComponent.kt | 193 ++++++++ .../viewholders/ChatNotificationViewHolder.kt | 16 + .../viewholders/FeedNotificationViewHolder.kt | 17 + .../NotificationTimelineViewHolder.kt | 15 + .../ui/viewholders/NotificationViewHolder.kt | 14 + .../ui/widgets/MessageTemplateImageView.kt | 169 +++++++ .../ui/widgets/NotificationRecyclerView.kt | 104 ++++ .../internal/ui/widgets/PagerRecyclerView.kt | 8 + .../internal/ui/widgets/RoundCornerLayout.kt | 73 +++ .../internal/ui/widgets/TemplateViews.kt | 186 +++++++ .../java/com/sendbird/uikit/model/Action.java | 88 ++++ .../com/sendbird/uikit/model/MessageData.java | 53 ++ .../uikit/model/MessageListUIParams.java | 4 +- .../modules/components/HeaderComponent.java | 2 +- .../components/MessageListComponent.java | 13 +- .../com/sendbird/uikit/utils/Available.java | 19 + .../sendbird/uikit/utils/DrawableUtils.java | 26 + .../sendbird/uikit/utils/MetricsUtils.java | 9 + .../sendbird/uikit/utils/ReactionUtils.java | 2 +- .../com/sendbird/uikit/utils/UIKitPrefs.java | 17 +- .../com/sendbird/uikit/utils/ViewUtils.java | 9 + .../uikit/vm/ChannelListViewModel.java | 6 +- .../vm/ChatNotificationChannelViewModel.java | 453 ++++++++++++++++++ .../vm/FeedNotificationChannelViewModel.java | 429 +++++++++++++++++ .../vm/NotificationViewModelFactory.java | 38 ++ .../sb_shape_round_rect_background_200.xml | 9 + .../sb_shape_round_rect_background_400.xml | 9 + .../drawable/sb_shape_timeline_background.xml | 8 +- .../res/layout/sb_view_chat_notification.xml | 6 + .../sb_view_chat_notification_component.xml | 61 +++ ...b_view_chat_notification_recycler_view.xml | 47 ++ .../res/layout/sb_view_feed_notification.xml | 6 + .../sb_view_feed_notification_component.xml | 69 +++ .../sb_view_open_channel_settings_info.xml | 4 +- .../sb_view_time_line_message_component.xml | 6 +- uikit/src/main/res/values/attrs.xml | 25 + uikit/src/main/res/values/strings.xml | 4 + uikit/src/main/res/values/styles.xml | 51 +- uikit/src/main/res/values/styles_dark.xml | 50 ++ 100 files changed, 6857 insertions(+), 82 deletions(-) create mode 100644 uikit/src/main/java/com/sendbird/uikit/activities/ChatNotificationChannelActivity.java create mode 100644 uikit/src/main/java/com/sendbird/uikit/activities/FeedNotificationChannelActivity.java create mode 100644 uikit/src/main/java/com/sendbird/uikit/fragments/ChatNotificationChannelFragment.java create mode 100644 uikit/src/main/java/com/sendbird/uikit/fragments/FeedNotificationChannelFragment.java create mode 100644 uikit/src/main/java/com/sendbird/uikit/interfaces/OnNotificationTemplateActionHandler.java create mode 100644 uikit/src/main/java/com/sendbird/uikit/internal/extensions/Resources.kt create mode 100644 uikit/src/main/java/com/sendbird/uikit/internal/extensions/Utils.kt create mode 100644 uikit/src/main/java/com/sendbird/uikit/internal/interfaces/Disposable.kt create mode 100644 uikit/src/main/java/com/sendbird/uikit/internal/interfaces/ViewRoundable.kt create mode 100644 uikit/src/main/java/com/sendbird/uikit/internal/model/ExtendedMessageType.kt create mode 100644 uikit/src/main/java/com/sendbird/uikit/internal/model/NotificationDiffCallback.kt create mode 100644 uikit/src/main/java/com/sendbird/uikit/internal/model/notifications/NotificationChannelTheme.kt create mode 100644 uikit/src/main/java/com/sendbird/uikit/internal/model/notifications/NotificationCommon.kt create mode 100644 uikit/src/main/java/com/sendbird/uikit/internal/model/notifications/NotificationConfig.kt create mode 100644 uikit/src/main/java/com/sendbird/uikit/internal/model/notifications/NotificationTemplate.kt create mode 100644 uikit/src/main/java/com/sendbird/uikit/internal/model/serializer/Serializers.kt create mode 100644 uikit/src/main/java/com/sendbird/uikit/internal/model/template_messages/Enums.kt create mode 100644 uikit/src/main/java/com/sendbird/uikit/internal/model/template_messages/KeySet.kt create mode 100644 uikit/src/main/java/com/sendbird/uikit/internal/model/template_messages/Params.kt create mode 100644 uikit/src/main/java/com/sendbird/uikit/internal/model/template_messages/Styles.kt create mode 100644 uikit/src/main/java/com/sendbird/uikit/internal/model/template_messages/Template.kt create mode 100644 uikit/src/main/java/com/sendbird/uikit/internal/model/template_messages/TemplateViewGenerator.kt create mode 100644 uikit/src/main/java/com/sendbird/uikit/internal/singleton/BaseSharedPreference.kt create mode 100644 uikit/src/main/java/com/sendbird/uikit/internal/singleton/MessageTemplateParser.kt create mode 100644 uikit/src/main/java/com/sendbird/uikit/internal/singleton/NotificationChannelManager.kt create mode 100644 uikit/src/main/java/com/sendbird/uikit/internal/singleton/NotificationChannelRepository.kt create mode 100644 uikit/src/main/java/com/sendbird/uikit/internal/singleton/NotificationParser.kt create mode 100644 uikit/src/main/java/com/sendbird/uikit/internal/singleton/NotificationTemplateRepository.kt create mode 100644 uikit/src/main/java/com/sendbird/uikit/internal/ui/messages/ChatNotificationView.kt create mode 100644 uikit/src/main/java/com/sendbird/uikit/internal/ui/messages/FeedNotificationView.kt create mode 100644 uikit/src/main/java/com/sendbird/uikit/internal/ui/notifications/ChatNotificationChannelModule.kt create mode 100644 uikit/src/main/java/com/sendbird/uikit/internal/ui/notifications/ChatNotificationHeaderComponent.kt create mode 100644 uikit/src/main/java/com/sendbird/uikit/internal/ui/notifications/ChatNotificationListAdapter.kt create mode 100644 uikit/src/main/java/com/sendbird/uikit/internal/ui/notifications/ChatNotificationListComponent.kt create mode 100644 uikit/src/main/java/com/sendbird/uikit/internal/ui/notifications/FeedNotificationChannelModule.kt create mode 100644 uikit/src/main/java/com/sendbird/uikit/internal/ui/notifications/FeedNotificationHeaderComponent.kt create mode 100644 uikit/src/main/java/com/sendbird/uikit/internal/ui/notifications/FeedNotificationListAdapter.kt create mode 100644 uikit/src/main/java/com/sendbird/uikit/internal/ui/notifications/FeedNotificationListComponent.kt create mode 100644 uikit/src/main/java/com/sendbird/uikit/internal/ui/notifications/NotificationListComponent.kt create mode 100644 uikit/src/main/java/com/sendbird/uikit/internal/ui/viewholders/ChatNotificationViewHolder.kt create mode 100644 uikit/src/main/java/com/sendbird/uikit/internal/ui/viewholders/FeedNotificationViewHolder.kt create mode 100644 uikit/src/main/java/com/sendbird/uikit/internal/ui/viewholders/NotificationTimelineViewHolder.kt create mode 100644 uikit/src/main/java/com/sendbird/uikit/internal/ui/viewholders/NotificationViewHolder.kt create mode 100644 uikit/src/main/java/com/sendbird/uikit/internal/ui/widgets/MessageTemplateImageView.kt create mode 100644 uikit/src/main/java/com/sendbird/uikit/internal/ui/widgets/NotificationRecyclerView.kt create mode 100755 uikit/src/main/java/com/sendbird/uikit/internal/ui/widgets/RoundCornerLayout.kt create mode 100644 uikit/src/main/java/com/sendbird/uikit/internal/ui/widgets/TemplateViews.kt create mode 100644 uikit/src/main/java/com/sendbird/uikit/model/Action.java create mode 100644 uikit/src/main/java/com/sendbird/uikit/model/MessageData.java create mode 100644 uikit/src/main/java/com/sendbird/uikit/vm/ChatNotificationChannelViewModel.java create mode 100644 uikit/src/main/java/com/sendbird/uikit/vm/FeedNotificationChannelViewModel.java create mode 100644 uikit/src/main/java/com/sendbird/uikit/vm/NotificationViewModelFactory.java create mode 100644 uikit/src/main/res/drawable/sb_shape_round_rect_background_200.xml create mode 100644 uikit/src/main/res/drawable/sb_shape_round_rect_background_400.xml create mode 100644 uikit/src/main/res/layout/sb_view_chat_notification.xml create mode 100644 uikit/src/main/res/layout/sb_view_chat_notification_component.xml create mode 100644 uikit/src/main/res/layout/sb_view_chat_notification_recycler_view.xml create mode 100644 uikit/src/main/res/layout/sb_view_feed_notification.xml create mode 100644 uikit/src/main/res/layout/sb_view_feed_notification_component.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index 94de6808..0ba5765a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,9 @@ # Changelog +### v3.5.0 (Mar 14, 2023) with Chat SDK `v4.6.0` +We’re excited to announce the launch of Sendbird Notifications v1.0! It’s a powerful solutions that makes it easier for brands to send marketing, transactional, and operational messages to their users. We’ve introduced a new type of channel called the notification channel that’s specifically designed for these kinds of messages. Just a heads up, you’ll need to use notification channels with Sendbird Notifications, otherwise things might not work properly. +* Support Notification Channel + * Added FeedNotificationChannelActivity` and `FeedNotificationChannelFragment` + * Added ChatNotificationChannelActivity` and `ChatNotificationChannelFragment` ### v3.4.0 (Feb 23, 2023) with Chat SDK `v4.4.0` * Support voice message in GroupChannel diff --git a/build.gradle b/build.gradle index 33a4203a..7e6ac940 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,10 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { ext.kotlin_version = '1.7.10' + + dependencies { + classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version" + } } plugins { id 'com.android.application' version '7.2.2' apply false diff --git a/gradle.properties b/gradle.properties index a5019b07..a901b35f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -16,5 +16,5 @@ org.gradle.jvmargs=-Xmx1536m # https://developer.android.com/topic/libraries/support-library/androidx-rn android.useAndroidX=true -UIKIT_VERSION = 3.4.0 +UIKIT_VERSION = 3.5.0 UIKIT_VERSION_CODE = 1 diff --git a/uikit-custom-sample/src/main/res/values-ko-rKR/strings.xml b/uikit-custom-sample/src/main/res/values-ko-rKR/strings.xml index 0ed4d768..93614151 100644 --- a/uikit-custom-sample/src/main/res/values-ko-rKR/strings.xml +++ b/uikit-custom-sample/src/main/res/values-ko-rKR/strings.xml @@ -155,6 +155,7 @@ 채널 설정 + 알림채널 99+ 커뮤니티 만들기 diff --git a/uikit/build.gradle b/uikit/build.gradle index e7cc33de..dcf59cdc 100644 --- a/uikit/build.gradle +++ b/uikit/build.gradle @@ -1,6 +1,7 @@ plugins { id 'com.android.library' id 'org.jetbrains.kotlin.android' + id 'kotlinx-serialization' } version = UIKIT_VERSION @@ -65,7 +66,7 @@ dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) // Sendbird - api 'com.sendbird.sdk:sendbird-chat:4.4.0' + api 'com.sendbird.sdk:sendbird-chat:4.6.0' implementation 'com.github.bumptech.glide:glide:4.13.0' annotationProcessor 'com.github.bumptech.glide:compiler:4.13.0' @@ -79,6 +80,7 @@ dependencies { implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.2" diff --git a/uikit/consumer-rules.pro b/uikit/consumer-rules.pro index 962f8c59..2dfca5ca 100644 --- a/uikit/consumer-rules.pro +++ b/uikit/consumer-rules.pro @@ -9,4 +9,50 @@ } -keep class com.bumptech.glide.load.data.ParcelFileDescriptorRewinder$InternalRewinder { *** rewind(); -} \ No newline at end of file +} + +# Kotlin Serialization +# Keep `Companion` object fields of serializable classes. +# This avoids serializer lookup through `getDeclaredClasses` as done for named companion objects. +-if @kotlinx.serialization.Serializable class ** +-keepclassmembers class com.sendbird.uikit.** { + static *** Companion; +} + +# Keep `serializer()` on companion objects (both default and named) of serializable classes. +-if @kotlinx.serialization.Serializable class ** { + static **$* *; +} +-keepclassmembers class com.sendbird.uikit.** { + kotlinx.serialization.KSerializer serializer(...); +} + +# Keep `INSTANCE.serializer()` of serializable objects. +-if @kotlinx.serialization.Serializable class ** { + public static ** INSTANCE; +} +-keepclassmembers class com.sendbird.uikit.** { + public static com.sendbird.uikit.** INSTANCE; + kotlinx.serialization.KSerializer serializer(...); +} + +# @Serializable and @Polymorphic are used at runtime for polymorphic serialization. +-keepattributes RuntimeVisibleAnnotations,AnnotationDefault + +-keepclassmembers class * { + *** writeReplace(); +} + +# Serializer for classes with named companion objects are retrieved using `getDeclaredClasses`. +# If you have any, uncomment and replace classes with those containing named companion objects. +#-keepattributes InnerClasses # Needed for `getDeclaredClasses`. +#-if @kotlinx.serialization.Serializable class +#com.example.myapplication.HasNamedCompanion, # <-- List serializable classes with named companions. +#com.example.myapplication.HasNamedCompanion2 +#{ +# static **$* *; +#} +#-keepnames class <1>$$serializer { # -keepnames suffices; class is kept when serializer() is kept. +# static <1>$$serializer INSTANCE; +#} + diff --git a/uikit/src/main/AndroidManifest.xml b/uikit/src/main/AndroidManifest.xml index 4ec50e68..58537cbe 100644 --- a/uikit/src/main/AndroidManifest.xml +++ b/uikit/src/main/AndroidManifest.xml @@ -132,6 +132,16 @@ android:name=".activities.OpenChannelActivity" android:configChanges="orientation|screenSize|keyboardHidden" android:windowSoftInputMode="adjustResize|stateHidden" /> + + + > onInitSucceed()"); FileUtils.removeDeletableDir(context.getApplicationContext()); UIKitPrefs.init(context.getApplicationContext()); + NotificationChannelManager.init(context.getApplicationContext()); EmojiManager.getInstance().init(); try { @@ -528,11 +532,29 @@ public Pair call() throws Exception { Logger.dev("++ user nickname = %s, profileUrl = %s", user.getNickname(), user.getProfileUrl()); - AppInfo appInfo = SendbirdChat.getAppInfo(); - if (appInfo != null && - appInfo.getUseReaction() && - appInfo.needUpdateEmoji(EmojiManager.getInstance().getEmojiHash())) { - updateEmojiList(); + final AppInfo appInfo = SendbirdChat.getAppInfo(); + if (appInfo != null) { + if (appInfo.getUseReaction() && + appInfo.needUpdateEmoji(EmojiManager.getInstance().getEmojiHash())) { + updateEmojiList(); + } + + final NotificationInfo notificationInfo = appInfo.getNotificationInfo(); + if (notificationInfo != null && notificationInfo.isEnabled()) { + // Even if the request fails, it should not affect the result of the connection request. + try { + // if the cache exists or no need to update, blocking is released right away + final String latestToken = notificationInfo.getTemplateListToken(); + NotificationChannelManager.requestTemplateListBlocking(latestToken); + } catch (Exception ignore) { + } + try { + // if the cache exists or no need to update, blocking is released right away + final long settingsUpdatedAt = notificationInfo.getSettingsUpdatedAt(); + NotificationChannelManager.requestNotificationChannelSettingBlocking(settingsUpdatedAt); + } catch (Exception ignore) { + } + } } } diff --git a/uikit/src/main/java/com/sendbird/uikit/activities/ChatNotificationChannelActivity.java b/uikit/src/main/java/com/sendbird/uikit/activities/ChatNotificationChannelActivity.java new file mode 100644 index 00000000..f62dcf93 --- /dev/null +++ b/uikit/src/main/java/com/sendbird/uikit/activities/ChatNotificationChannelActivity.java @@ -0,0 +1,84 @@ +package com.sendbird.uikit.activities; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; + +import com.sendbird.uikit.R; +import com.sendbird.uikit.SendbirdUIKit; +import com.sendbird.uikit.consts.StringSet; +import com.sendbird.uikit.utils.ContextUtils; +import com.sendbird.uikit.utils.TextUtils; + +/** + * Activity displays notifications of a channel by user. + */ +public class ChatNotificationChannelActivity extends AppCompatActivity { + + /** + * Create an intent for a {@link ChatNotificationChannelActivity}. + * + * @param context A Context of the application package implementing this class. + * @param channelUrl the url of the channel will be implemented. + * @return ChatNotificationChannelActivity Intent. + * @since 3.5.0 + */ + @NonNull + public static Intent newIntent(@NonNull Context context, @NonNull String channelUrl) { + return newIntentFromCustomActivity(context, ChatNotificationChannelActivity.class, channelUrl); + } + + /** + * Create an intent for a custom activity. The custom activity must inherit {@link ChatNotificationChannelActivity}. + * + * @param context A Context of the application package implementing this class. + * @param cls The activity class that is to be used for the intent. + * @param channelUrl the url of the channel will be implemented. + * @return Returns a newly created Intent that can be used to launch the activity. + * @since 3.5.0 + */ + @NonNull + public static Intent newIntentFromCustomActivity(@NonNull Context context, @NonNull Class cls, @NonNull String channelUrl) { + Intent intent = new Intent(context, cls); + intent.putExtra(StringSet.KEY_CHANNEL_URL, channelUrl); + return intent; + } + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setTheme(SendbirdUIKit.isDarkMode() ? R.style.AppTheme_Dark_Sendbird : R.style.AppTheme_Sendbird); + setContentView(R.layout.sb_activity); + + String url = getIntent().getStringExtra(StringSet.KEY_CHANNEL_URL); + if (TextUtils.isEmpty(url)) { + ContextUtils.toastError(this, R.string.sb_text_error_get_channel); + } else { + Fragment fragment = createFragment(); + FragmentManager manager = getSupportFragmentManager(); + manager.popBackStack(); + manager.beginTransaction() + .replace(R.id.sb_fragment_container, fragment) + .commit(); + } + } + + /** + * It will be called when the {@link ChatNotificationChannelActivity} is being created. + * The data contained in Intent is delivered to Fragment's Bundle. + * + * @return {@link com.sendbird.uikit.fragments.ChatNotificationChannelFragment} + * @since 3.5.0 + */ + @NonNull + protected Fragment createFragment() { + final Bundle args = getIntent() != null && getIntent().getExtras() != null ? getIntent().getExtras() : new Bundle(); + return SendbirdUIKit.getFragmentFactory().newChatNotificationChannelFragment(args.getString(StringSet.KEY_CHANNEL_URL, ""), args); + } +} diff --git a/uikit/src/main/java/com/sendbird/uikit/activities/FeedNotificationChannelActivity.java b/uikit/src/main/java/com/sendbird/uikit/activities/FeedNotificationChannelActivity.java new file mode 100644 index 00000000..cc12d92e --- /dev/null +++ b/uikit/src/main/java/com/sendbird/uikit/activities/FeedNotificationChannelActivity.java @@ -0,0 +1,84 @@ +package com.sendbird.uikit.activities; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; + +import com.sendbird.uikit.R; +import com.sendbird.uikit.SendbirdUIKit; +import com.sendbird.uikit.consts.StringSet; +import com.sendbird.uikit.utils.ContextUtils; +import com.sendbird.uikit.utils.TextUtils; + +/** + * Activity displays notifications of a channel by user. + */ +public class FeedNotificationChannelActivity extends AppCompatActivity { + + /** + * Create an intent for a {@link FeedNotificationChannelActivity}. + * + * @param context A Context of the application package implementing this class. + * @param channelUrl the url of the channel will be implemented. + * @return FeedNotificationChannelActivity Intent. + * @since 3.5.0 + */ + @NonNull + public static Intent newIntent(@NonNull Context context, @NonNull String channelUrl) { + return newIntentFromCustomActivity(context, FeedNotificationChannelActivity.class, channelUrl); + } + + /** + * Create an intent for a custom activity. The custom activity must inherit {@link FeedNotificationChannelActivity}. + * + * @param context A Context of the application package implementing this class. + * @param cls The activity class that is to be used for the intent. + * @param channelUrl the url of the channel will be implemented. + * @return Returns a newly created Intent that can be used to launch the activity. + * @since 3.5.0 + */ + @NonNull + public static Intent newIntentFromCustomActivity(@NonNull Context context, @NonNull Class cls, @NonNull String channelUrl) { + Intent intent = new Intent(context, cls); + intent.putExtra(StringSet.KEY_CHANNEL_URL, channelUrl); + return intent; + } + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setTheme(SendbirdUIKit.isDarkMode() ? R.style.AppTheme_Dark_Sendbird : R.style.AppTheme_Sendbird); + setContentView(R.layout.sb_activity); + + String url = getIntent().getStringExtra(StringSet.KEY_CHANNEL_URL); + if (TextUtils.isEmpty(url)) { + ContextUtils.toastError(this, R.string.sb_text_error_get_channel); + } else { + Fragment fragment = createFragment(); + FragmentManager manager = getSupportFragmentManager(); + manager.popBackStack(); + manager.beginTransaction() + .replace(R.id.sb_fragment_container, fragment) + .commit(); + } + } + + /** + * It will be called when the {@link FeedNotificationChannelActivity} is being created. + * The data contained in Intent is delivered to Fragment's Bundle. + * + * @return {@link com.sendbird.uikit.fragments.FeedNotificationChannelFragment} + * @since 3.5.0 + */ + @NonNull + protected Fragment createFragment() { + final Bundle args = getIntent() != null && getIntent().getExtras() != null ? getIntent().getExtras() : new Bundle(); + return SendbirdUIKit.getFragmentFactory().newFeedNotificationChannelFragment(args.getString(StringSet.KEY_CHANNEL_URL, ""), args); + } +} diff --git a/uikit/src/main/java/com/sendbird/uikit/activities/adapter/BaseMessageAdapter.java b/uikit/src/main/java/com/sendbird/uikit/activities/adapter/BaseMessageAdapter.java index d7d53f3e..85bc6256 100644 --- a/uikit/src/main/java/com/sendbird/uikit/activities/adapter/BaseMessageAdapter.java +++ b/uikit/src/main/java/com/sendbird/uikit/activities/adapter/BaseMessageAdapter.java @@ -2,17 +2,15 @@ import androidx.recyclerview.widget.RecyclerView; -import com.sendbird.uikit.activities.viewholder.MessageViewHolder; - import java.util.List; /** * BaseMessageAdapter provides a binding from an app-specific data set to views that are displayed within a RecyclerView. * * @param A class of data's type. - * @param A class that extends MessageViewHolder that will be used by the adapter. + * @param A class that extends RecyclerView.ViewHolder that will be used by the adapter. */ -abstract class BaseMessageAdapter extends RecyclerView.Adapter { +abstract class BaseMessageAdapter extends RecyclerView.Adapter { /** * Returns item that located given position. * diff --git a/uikit/src/main/java/com/sendbird/uikit/activities/adapter/ChannelListAdapter.java b/uikit/src/main/java/com/sendbird/uikit/activities/adapter/ChannelListAdapter.java index c99cb51d..f8eec903 100644 --- a/uikit/src/main/java/com/sendbird/uikit/activities/adapter/ChannelListAdapter.java +++ b/uikit/src/main/java/com/sendbird/uikit/activities/adapter/ChannelListAdapter.java @@ -257,7 +257,7 @@ static class ChannelInfo { this.createdAt = channel.getCreatedAt(); this.memberCount = channel.getMemberCount(); this.lastMessage = channel.getLastMessage(); - this.channelName = channel.getName() != null ? channel.getName() : ""; + this.channelName = channel.getName(); this.coverImageUrl = channel.getCoverUrl(); this.pushTriggerOption = channel.getMyPushTriggerOption(); this.unreadMessageCount = channel.getUnreadMessageCount(); @@ -268,8 +268,10 @@ static class ChannelInfo { this.typingMembers = channel.getTypingUsers(); } if (SendbirdUIKit.isUsingChannelListMessageReceiptStatus()) { - this.unReadMemberCount = channel.getUnreadMemberCount(channel.getLastMessage()); - this.unDeliveredMemberCount = channel.getUndeliveredMemberCount(channel.getLastMessage()); + if (channel.getLastMessage() != null) { + this.unReadMemberCount = channel.getUnreadMemberCount(channel.getLastMessage()); + this.unDeliveredMemberCount = channel.getUndeliveredMemberCount(channel.getLastMessage()); + } } } diff --git a/uikit/src/main/java/com/sendbird/uikit/activities/adapter/MessageListAdapter.java b/uikit/src/main/java/com/sendbird/uikit/activities/adapter/MessageListAdapter.java index 3ce13c18..4742934f 100644 --- a/uikit/src/main/java/com/sendbird/uikit/activities/adapter/MessageListAdapter.java +++ b/uikit/src/main/java/com/sendbird/uikit/activities/adapter/MessageListAdapter.java @@ -10,7 +10,6 @@ * MessageListAdapter provides a binding from a {@link BaseMessage} type data set to views that are displayed within a RecyclerView. */ public class MessageListAdapter extends BaseMessageListAdapter { - public MessageListAdapter(boolean useMessageGroupUI) { this(null, useMessageGroupUI); } diff --git a/uikit/src/main/java/com/sendbird/uikit/activities/viewholder/MessageType.java b/uikit/src/main/java/com/sendbird/uikit/activities/viewholder/MessageType.java index 8180b46a..f49b1025 100644 --- a/uikit/src/main/java/com/sendbird/uikit/activities/viewholder/MessageType.java +++ b/uikit/src/main/java/com/sendbird/uikit/activities/viewholder/MessageType.java @@ -61,6 +61,18 @@ public enum MessageType { * @since 3.3.0 */ VIEW_TYPE_PARENT_MESSAGE_INFO(12), + /** + * Type of chat notification channel's message sent by the administrator. + * + * @since 3.5.0 + */ + VIEW_TYPE_CHAT_NOTIFICATION(13), + /** + * Type of feed notification channel's message sent by the administrator. + * + * @since 3.5.0 + */ + VIEW_TYPE_FEED_NOTIFICATION(14), /** * Type of voice message sent by the current user. * diff --git a/uikit/src/main/java/com/sendbird/uikit/consts/StringSet.java b/uikit/src/main/java/com/sendbird/uikit/consts/StringSet.java index 0142c6cb..529fc6c4 100644 --- a/uikit/src/main/java/com/sendbird/uikit/consts/StringSet.java +++ b/uikit/src/main/java/com/sendbird/uikit/consts/StringSet.java @@ -22,7 +22,6 @@ public class StringSet { public final static String KEY_DELETABLE_MESSAGE = "KEY_DELETABLE_MESSAGE"; public final static String KEY_USE_USER_PROFILE = "KEY_USE_USER_PROFILE"; - public final static String KEY_STARTING_POINT = "KEY_STARTING_POINT"; public final static String KEY_PARENT_MESSAGE = "KEY_PARENT_MESSAGE"; public final static String KEY_TRY_ANIMATE_WHEN_MESSAGE_LOADED = "KEY_MESSAGE_INITIAL_ANIMATE"; @@ -135,4 +134,10 @@ public class StringSet { public final static String ThreadInfo = "ThreadInfo"; public final static String ParentMessageMenu = "ParentMessageMenu"; public final static String _AT = "@"; + + // template message syntax + public final static String web = "web"; + public final static String custom = "custom"; + public final static String uikit = "uikit"; + public final static String delete = "delete"; } diff --git a/uikit/src/main/java/com/sendbird/uikit/fragments/ChannelFragment.java b/uikit/src/main/java/com/sendbird/uikit/fragments/ChannelFragment.java index 52d84754..df45ebcf 100644 --- a/uikit/src/main/java/com/sendbird/uikit/fragments/ChannelFragment.java +++ b/uikit/src/main/java/com/sendbird/uikit/fragments/ChannelFragment.java @@ -69,6 +69,7 @@ import com.sendbird.uikit.vm.ViewModelFactory; import com.sendbird.uikit.widgets.MentionEditText; import com.sendbird.uikit.widgets.MessageInputView; +import com.sendbird.uikit.widgets.StatusFrameView; import java.util.ArrayList; import java.util.Arrays; @@ -172,7 +173,7 @@ protected void onBeforeReady(@NonNull ReadyStatus status, @NonNull ChannelModule protected void onReady(@NonNull ReadyStatus status, @NonNull ChannelModule module, @NonNull ChannelViewModel viewModel) { shouldDismissLoadingDialog(); final GroupChannel channel = viewModel.getChannel(); - if (status == ReadyStatus.ERROR || channel == null) { + if (status == ReadyStatus.ERROR || channel == null || channel.isChatNotification()) { if (isFragmentAlive()) { toastError(R.string.sb_text_error_get_channel); shouldActivityFinish(); @@ -435,6 +436,10 @@ protected void onBindMessageInputComponent(@NonNull MessageInputComponent inputC */ protected void onBindStatusComponent(@NonNull StatusComponent statusComponent, @NonNull ChannelViewModel viewModel, @Nullable GroupChannel channel) { Logger.d(">> ChannelFragment::onBindStatusComponent()"); + statusComponent.setOnActionButtonClickListener(v -> { + statusComponent.notifyStatusChanged(StatusFrameView.Status.LOADING); + shouldAuthenticate(); + }); viewModel.getStatusFrame().observe(getViewLifecycleOwner(), statusComponent::notifyStatusChanged); } @@ -511,6 +516,7 @@ private void onInputRightButtonClicked(@NonNull View view) { params.setMentionedUsers(mentionedUsers); } } + sendUserMessage(params); } } diff --git a/uikit/src/main/java/com/sendbird/uikit/fragments/ChannelListFragment.java b/uikit/src/main/java/com/sendbird/uikit/fragments/ChannelListFragment.java index fad3addd..c64f0e10 100644 --- a/uikit/src/main/java/com/sendbird/uikit/fragments/ChannelListFragment.java +++ b/uikit/src/main/java/com/sendbird/uikit/fragments/ChannelListFragment.java @@ -18,6 +18,7 @@ import com.sendbird.uikit.R; import com.sendbird.uikit.SendbirdUIKit; import com.sendbird.uikit.activities.ChannelActivity; +import com.sendbird.uikit.activities.ChatNotificationChannelActivity; import com.sendbird.uikit.activities.CreateChannelActivity; import com.sendbird.uikit.activities.adapter.ChannelListAdapter; import com.sendbird.uikit.consts.CreatableChannelType; @@ -168,13 +169,18 @@ private void showChannelTypeSelectDialog() { } } - private void startChannelActivity(@NonNull String channelUrl) { + private void startChannelActivity(@NonNull GroupChannel channel) { if (isFragmentAlive()) { - startActivity(ChannelActivity.newIntent(requireContext(), channelUrl)); + if (channel.isChatNotification()) { + startActivity(ChatNotificationChannelActivity.newIntent(requireContext(), channel.getUrl())); + } else { + startActivity(ChannelActivity.newIntent(requireContext(), channel.getUrl())); + } } } private void showListContextMenu(@NonNull GroupChannel channel) { + if (channel.isChatNotification()) return; DialogListItem pushOnOff = new DialogListItem(ChannelUtils.isChannelPushOff(channel) ? R.string.sb_text_channel_list_push_on : R.string.sb_text_channel_list_push_off); DialogListItem leaveChannel = new DialogListItem(R.string.sb_text_channel_list_leave); DialogListItem[] items = {pushOnOff, leaveChannel}; @@ -236,7 +242,7 @@ protected void onItemClicked(@NonNull View view, int position, @NonNull GroupCha itemClickListener.onItemClick(view, position, channel); return; } - startChannelActivity(channel.getUrl()); + startChannelActivity(channel); } /** diff --git a/uikit/src/main/java/com/sendbird/uikit/fragments/ChatNotificationChannelFragment.java b/uikit/src/main/java/com/sendbird/uikit/fragments/ChatNotificationChannelFragment.java new file mode 100644 index 00000000..9983bc9d --- /dev/null +++ b/uikit/src/main/java/com/sendbird/uikit/fragments/ChatNotificationChannelFragment.java @@ -0,0 +1,394 @@ +package com.sendbird.uikit.fragments; + +import android.content.ActivityNotFoundException; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StyleRes; +import androidx.lifecycle.ViewModelProvider; + +import com.sendbird.android.channel.GroupChannel; +import com.sendbird.android.message.BaseMessage; +import com.sendbird.android.params.MessageListParams; +import com.sendbird.uikit.R; +import com.sendbird.uikit.SendbirdUIKit; +import com.sendbird.uikit.consts.StringSet; +import com.sendbird.uikit.interfaces.OnNotificationTemplateActionHandler; +import com.sendbird.uikit.internal.model.notifications.NotificationChannelSettings; +import com.sendbird.uikit.internal.model.notifications.NotificationConfig; +import com.sendbird.uikit.internal.singleton.NotificationChannelManager; +import com.sendbird.uikit.internal.ui.notifications.ChatNotificationChannelModule; +import com.sendbird.uikit.internal.ui.notifications.ChatNotificationHeaderComponent; +import com.sendbird.uikit.internal.ui.notifications.ChatNotificationListComponent; +import com.sendbird.uikit.log.Logger; +import com.sendbird.uikit.model.Action; +import com.sendbird.uikit.model.ReadyStatus; +import com.sendbird.uikit.modules.components.StatusComponent; +import com.sendbird.uikit.utils.IntentUtils; +import com.sendbird.uikit.utils.TextUtils; +import com.sendbird.uikit.vm.ChatNotificationChannelViewModel; +import com.sendbird.uikit.vm.NotificationViewModelFactory; +import com.sendbird.uikit.widgets.StatusFrameView; + +public class ChatNotificationChannelFragment extends BaseModuleFragment { + + @Nullable + private OnNotificationTemplateActionHandler actionHandler; + @Nullable + private MessageListParams params; + + @NonNull + @Override + protected ChatNotificationChannelModule onCreateModule(@NonNull Bundle args) { + final NotificationChannelSettings settings = NotificationChannelManager.getGlobalNotificationChannelSettings(); + return new ChatNotificationChannelModule(requireContext(), NotificationConfig.from(settings)); + } + + @Override + protected void onConfigureParams(@NonNull ChatNotificationChannelModule module, @NonNull Bundle args) { + } + + @NonNull + @Override + protected ChatNotificationChannelViewModel onCreateViewModel() { + final ChatNotificationChannelViewModel viewModel = new ViewModelProvider(this, new NotificationViewModelFactory(getChannelUrl(), params)).get(getChannelUrl(), ChatNotificationChannelViewModel.class); + getLifecycle().addObserver(viewModel); + return viewModel; + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + shouldShowLoadingDialog(); + } + + @Override + public void onDestroy() { + super.onDestroy(); + shouldDismissLoadingDialog(); + } + + @Override + protected void onBeforeReady(@NonNull ReadyStatus status, @NonNull ChatNotificationChannelModule module, @NonNull ChatNotificationChannelViewModel viewModel) { + Logger.d(">> ChatNotificationChannelFragment::onBeforeReady status=%s", status); + module.getNotificationListComponent().setPagedDataLoader(viewModel); + final GroupChannel channel = viewModel.getChannel(); + onBindHeaderComponent(module.getHeaderComponent(), viewModel, channel); + onBindNotificationListComponent(module.getNotificationListComponent(), viewModel, channel); + onBindStatusComponent(module.getStatusComponent(), viewModel, channel); + } + + @Override + protected void onReady(@NonNull ReadyStatus status, @NonNull ChatNotificationChannelModule module, @NonNull ChatNotificationChannelViewModel viewModel) { + Logger.d(">> ChatNotificationChannelFragment::onReady status=%s", status); + shouldDismissLoadingDialog(); + final GroupChannel channel = viewModel.getChannel(); + if (status == ReadyStatus.ERROR || channel == null) { + if (isFragmentAlive()) { + toastError(R.string.sb_text_error_get_channel); + shouldActivityFinish(); + } + return; + } + + module.getHeaderComponent().notifyChannelChanged(channel); + module.getNotificationListComponent().notifyChannelChanged(channel); + viewModel.onChannelDeleted().observe(getViewLifecycleOwner(), channelUrl -> shouldActivityFinish()); + loadInitial(); + } + + /** + * Called to bind events to the ChatNotificationHeaderComponent. This is called from {@link #onBeforeReady(ReadyStatus, ChatNotificationChannelModule, ChatNotificationChannelViewModel)} regardless of the value of {@link ReadyStatus}. + * + * @param headerComponent The component to which the event will be bound + * @param viewModel A view model that provides the data needed for the fragment + * @param channel The {@code GroupChannel} that contains the data needed for this fragment + * @since 3.5.0 + */ + protected void onBindHeaderComponent(@NonNull ChatNotificationHeaderComponent headerComponent, @NonNull ChatNotificationChannelViewModel viewModel, @Nullable GroupChannel channel) { + Logger.d(">> ChatNotificationChannelFragment::onChatNotificationHeaderComponent()"); + headerComponent.setOnLeftButtonClickListener(v -> shouldActivityFinish()); + viewModel.onChannelUpdated().observe(getViewLifecycleOwner(), headerComponent::notifyChannelChanged); + } + + /** + * Called to bind events to the ChatNotificationListComponent. This is called from {@link #onBeforeReady(ReadyStatus, ChatNotificationChannelModule, ChatNotificationChannelViewModel)} regardless of the value of {@link ReadyStatus}. + * + * @param listComponent The component to which the event will be bound + * @param viewModel A view model that provides the data needed for the fragment + * @param channel The {@code GroupChannel} that contains the data needed for this fragment + * @since 3.5.0 + */ + protected void onBindNotificationListComponent(@NonNull ChatNotificationListComponent listComponent, @NonNull ChatNotificationChannelViewModel viewModel, @Nullable GroupChannel channel) { + Logger.d(">> ChatNotificationChannelFragment::onBindChatNotificationListComponent()"); + listComponent.setOnMessageTemplateActionHandler(actionHandler != null ? actionHandler : this::handleAction); + listComponent.setOnTooltipClickListener(v -> listComponent.scrollToFirst()); + + viewModel.onChannelUpdated().observe(getViewLifecycleOwner(), listComponent::notifyChannelChanged); + viewModel.getNotificationList().observeAlways(getViewLifecycleOwner(), notificationData -> { + Logger.d("++ notification data = %s", notificationData); + if (!isFragmentAlive() || channel == null) return; + + final String eventSource = notificationData.getTraceName(); + listComponent.notifyDataSetChanged(notificationData.getMessages(), channel, notifications -> { + if (!isFragmentAlive() || eventSource == null) return; + + switch (eventSource) { + case StringSet.EVENT_MESSAGE_RECEIVED: + listComponent.notifyNewNotificationReceived(); + break; + case StringSet.ACTION_INIT_FROM_REMOTE: + case StringSet.MESSAGE_CHANGELOG: + case StringSet.MESSAGE_FILL: + listComponent.notifyMessagesFilled(); + break; + default: + break; + } + }); + }); + } + + /** + * Called to bind events to the StatusComponent. This is called from {@link #onBeforeReady(ReadyStatus, ChatNotificationChannelModule, ChatNotificationChannelViewModel)} regardless of the value of {@link ReadyStatus}. + * + * @param statusComponent The component to which the event will be bound + * @param viewModel A view model that provides the data needed for the fragment + * @param channel The {@code GroupChannel} that contains the data needed for this fragment + * @since 3.5.0 + */ + protected void onBindStatusComponent(@NonNull StatusComponent statusComponent, @NonNull ChatNotificationChannelViewModel viewModel, @Nullable GroupChannel channel) { + Logger.d(">> ChatNotificationChannelFragment::onBindStatusComponent()"); + statusComponent.setOnActionButtonClickListener(v -> { + statusComponent.notifyStatusChanged(StatusFrameView.Status.LOADING); + shouldAuthenticate(); + }); + viewModel.getStatusFrame().observe(getViewLifecycleOwner(), statusComponent::notifyStatusChanged); + } + + private void handleAction(@NonNull View view, @NonNull Action action, @NonNull BaseMessage message) { + switch (action.getType()) { + case StringSet.web: + handleWebAction(view, action, message); + break; + case StringSet.custom: + handleCustomAction(view, action, message); + break; + default: + break; + } + } + + /** + * If an Action is registered in a specific view, it is called when a click event occurs. + * + * @param action the registered Action data + * @param message a clicked message + * @since 3.5.0 + */ + protected void handleWebAction(@NonNull View view, @NonNull Action action, @NonNull BaseMessage message) { + Logger.d(">> ChatNotificationChannelFragment::handleWebAction() action=%s", action); + final Intent intent = IntentUtils.getWebViewerIntent(action.getData()); + try { + startActivity(intent); + } catch (ActivityNotFoundException e) { + Logger.e(e); + } + } + + /** + * If an Action is registered in a specific view, it is called when a click event occurs. + * + * @param action the registered Action data + * @param message a clicked message + * @since 3.5.0 + */ + protected void handleCustomAction(@NonNull View view, @NonNull Action action, @NonNull BaseMessage message) { + Logger.d(">> ChatNotificationChannelFragment::handleCustomAction() action=%s", action); + try { + final String data = action.getData(); + if (TextUtils.isNotEmpty(data)) { + final Uri uri = Uri.parse(data); + Logger.d("++ uri = %s", uri); + final String scheme = uri.getScheme(); + Logger.d("++ scheme=%s", scheme); + Intent intent = new Intent(Intent.ACTION_VIEW, uri); + boolean hasIntent = IntentUtils.hasIntent(requireContext(), intent); + if (!hasIntent) { + final String alterData = action.getAlterData(); + intent = new Intent(Intent.ACTION_VIEW, Uri.parse(alterData)); + } + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + startActivity(intent); + } + } catch (Exception e) { + Logger.w(e); + } + } + + /** + * It will be called when the loading dialog needs displaying. + * + * @return True if the callback has consumed the event, false otherwise. + * @since 3.5.0 + */ + protected boolean shouldShowLoadingDialog() { + return getModule().shouldShowLoadingDialog(); + } + + /** + * It will be called when the loading dialog needs dismissing. + * + * @since 3.5.0 + */ + protected void shouldDismissLoadingDialog() { + getModule().shouldDismissLoadingDialog(); + } + + private synchronized void loadInitial() { + getViewModel().loadInitial(Long.MAX_VALUE); + } + + /** + * Returns the URL of the channel with the required data to use this fragment. + * + * @return The URL of a channel this fragment is currently associated with + * @since 3.5.0 + */ + @NonNull + protected String getChannelUrl() { + final Bundle args = getArguments() == null ? new Bundle() : getArguments(); + return args.getString(StringSet.KEY_CHANNEL_URL, ""); + } + + public static class Builder { + @NonNull + private final Bundle bundle; + @Nullable + private ChatNotificationChannelFragment customFragment; + @Nullable + private OnNotificationTemplateActionHandler actionHandler; + @Nullable + private MessageListParams params; + + /** + * Constructor + * + * @param channelUrl the url of the channel will be implemented. + * @since 3.5.0 + */ + public Builder(@NonNull String channelUrl) { + this(channelUrl, SendbirdUIKit.getDefaultThemeMode()); + } + + /** + * Constructor + * + * @param channelUrl the url of the channel will be implemented. + * @param themeMode {@link SendbirdUIKit.ThemeMode} + * @since 3.5.0 + */ + public Builder(@NonNull String channelUrl, @NonNull SendbirdUIKit.ThemeMode themeMode) { + this(channelUrl, themeMode.getResId()); + } + + /** + * Constructor + * + * @param channelUrl the url of the channel will be implemented. + * @param customThemeResId the resource identifier for custom theme. + * @since 3.5.0 + */ + public Builder(@NonNull String channelUrl, @StyleRes int customThemeResId) { + bundle = new Bundle(); + bundle.putInt(StringSet.KEY_THEME_RES_ID, customThemeResId); + bundle.putString(StringSet.KEY_CHANNEL_URL, channelUrl); + } + + /** + * Sets arguments to this fragment. + * + * @param args the arguments supplied when the fragment was instantiated. + * @return This Builder object to allow for chaining of calls to set methods. + * @since 3.5.0 + */ + @NonNull + public Builder withArguments(@NonNull Bundle args) { + this.bundle.putAll(args); + return this; + } + + /** + * Sets the click listener on the message template view clicked. + * Sets the click listener when the view component that has {@link com.sendbird.uikit.model.Action} is clicked + * + * @param handler The callback that will run. + * @return This Builder object to allow for chaining of calls to set methods. + * @since 3.5.0 + */ + @NonNull + public Builder setOnMessageTemplateActionHandler(@NonNull OnNotificationTemplateActionHandler handler) { + this.actionHandler = handler; + return this; + } + + /** + * Sets whether the header is used. + * + * @param useHeader true if the header is used, false otherwise. + * @return This Builder object to allow for chaining of calls to set methods. + * @since 3.5.0 + */ + @NonNull + public Builder setUseHeader(boolean useHeader) { + bundle.putBoolean(StringSet.KEY_USE_HEADER, useHeader); + return this; + } + + /** + * Sets the custom fragment. It must inherit {@link ChatNotificationChannelFragment}. + * + * @param fragment custom fragment. + * @return This Builder object to allow for chaining of calls to set methods. + * @since 3.5.0 + */ + @NonNull + public Builder setCustomFragment(T fragment) { + this.customFragment = fragment; + return this; + } + + /** + * Sets the notification list params for this channel. + * The reverse property in the MessageListParams are used in the UIKit. Even though you set that property it will be ignored. + * + * @param params The MessageListParams instance that you want to use. + * @return This Builder object to allow for chaining of calls to set methods. + * @since 3.5.0 + */ + @NonNull + public Builder setNotificationListParams(@NonNull MessageListParams params) { + this.params = params; + return this; + } + + /** + * Creates an {@link ChatNotificationChannelFragment} with the arguments supplied to this + * builder. + * + * @return The {@link ChatNotificationChannelFragment} applied to the {@link Bundle}. + * @since 3.5.0 + */ + @NonNull + public ChatNotificationChannelFragment build() { + ChatNotificationChannelFragment fragment = customFragment != null ? customFragment : new ChatNotificationChannelFragment(); + fragment.setArguments(bundle); + fragment.params = params; + fragment.actionHandler = actionHandler; + return fragment; + } + } +} diff --git a/uikit/src/main/java/com/sendbird/uikit/fragments/FeedNotificationChannelFragment.java b/uikit/src/main/java/com/sendbird/uikit/fragments/FeedNotificationChannelFragment.java new file mode 100644 index 00000000..c422998a --- /dev/null +++ b/uikit/src/main/java/com/sendbird/uikit/fragments/FeedNotificationChannelFragment.java @@ -0,0 +1,407 @@ +package com.sendbird.uikit.fragments; + +import android.content.ActivityNotFoundException; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StyleRes; +import androidx.lifecycle.ViewModelProvider; + +import com.sendbird.android.channel.FeedChannel; +import com.sendbird.android.message.BaseMessage; +import com.sendbird.android.params.MessageListParams; +import com.sendbird.uikit.SendbirdUIKit; +import com.sendbird.uikit.consts.StringSet; +import com.sendbird.uikit.interfaces.OnNotificationTemplateActionHandler; +import com.sendbird.uikit.internal.model.notifications.NotificationChannelSettings; +import com.sendbird.uikit.internal.model.notifications.NotificationConfig; +import com.sendbird.uikit.internal.singleton.NotificationChannelManager; +import com.sendbird.uikit.internal.ui.notifications.FeedNotificationChannelModule; +import com.sendbird.uikit.internal.ui.notifications.FeedNotificationHeaderComponent; +import com.sendbird.uikit.internal.ui.notifications.FeedNotificationListComponent; +import com.sendbird.uikit.log.Logger; +import com.sendbird.uikit.model.Action; +import com.sendbird.uikit.model.ReadyStatus; +import com.sendbird.uikit.modules.components.StatusComponent; +import com.sendbird.uikit.utils.IntentUtils; +import com.sendbird.uikit.utils.TextUtils; +import com.sendbird.uikit.vm.FeedNotificationChannelViewModel; +import com.sendbird.uikit.vm.NotificationViewModelFactory; +import com.sendbird.uikit.widgets.StatusFrameView; + +public class FeedNotificationChannelFragment extends BaseModuleFragment { + + @Nullable + private OnNotificationTemplateActionHandler actionHandler; + @Nullable + private MessageListParams params; + + @NonNull + @Override + protected FeedNotificationChannelModule onCreateModule(@NonNull Bundle args) { + final NotificationChannelSettings settings = NotificationChannelManager.getGlobalNotificationChannelSettings(); + return new FeedNotificationChannelModule(requireContext(), NotificationConfig.from(settings)); + } + + @Override + protected void onConfigureParams(@NonNull FeedNotificationChannelModule module, @NonNull Bundle args) { + } + + @NonNull + @Override + protected FeedNotificationChannelViewModel onCreateViewModel() { + final FeedNotificationChannelViewModel viewModel = new ViewModelProvider(this, new NotificationViewModelFactory(getChannelUrl(), params)).get(getChannelUrl(), FeedNotificationChannelViewModel.class); + getLifecycle().addObserver(viewModel); + return viewModel; + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + shouldShowLoadingDialog(); + } + + @Override + public void onDestroy() { + super.onDestroy(); + shouldDismissLoadingDialog(); + } + + @Override + protected void onBeforeReady(@NonNull ReadyStatus status, @NonNull FeedNotificationChannelModule module, @NonNull FeedNotificationChannelViewModel viewModel) { + Logger.d(">> FeedNotificationChannelFragment::onBeforeReady status=%s", status); + module.getNotificationListComponent().setPagedDataLoader(viewModel); + final FeedChannel channel = viewModel.getChannel(); + onBindHeaderComponent(module.getHeaderComponent(), viewModel, channel); + onBindNotificationListComponent(module.getNotificationListComponent(), viewModel, channel); + onBindStatusComponent(module.getStatusComponent(), viewModel, channel); + } + + @Override + protected void onReady(@NonNull ReadyStatus status, @NonNull FeedNotificationChannelModule module, @NonNull FeedNotificationChannelViewModel viewModel) { + Logger.d(">> FeedNotificationChannelFragment::onReady status=%s", status); + shouldDismissLoadingDialog(); + final FeedChannel channel = viewModel.getChannel(); + if (status == ReadyStatus.ERROR || channel == null) { + final StatusComponent statusComponent = module.getStatusComponent(); + statusComponent.notifyStatusChanged(StatusFrameView.Status.CONNECTION_ERROR); + return; + } + + module.getHeaderComponent().notifyChannelChanged(channel); + module.getNotificationListComponent().notifyChannelChanged(channel); + viewModel.onChannelDeleted().observe(getViewLifecycleOwner(), channelUrl -> shouldActivityFinish()); + loadInitial(); + } + + /** + * Called to bind events to the FeedNotificationHeaderComponent. This is called from {@link #onBeforeReady(ReadyStatus, FeedNotificationChannelModule, FeedNotificationChannelViewModel)} regardless of the value of {@link ReadyStatus}. + * + * @param headerComponent The component to which the event will be bound + * @param viewModel A view model that provides the data needed for the fragment + * @param channel The {@code FeedChannel} that contains the data needed for this fragment + * @since 3.5.0 + */ + protected void onBindHeaderComponent(@NonNull FeedNotificationHeaderComponent headerComponent, @NonNull FeedNotificationChannelViewModel viewModel, @Nullable FeedChannel channel) { + Logger.d(">> FeedNotificationChannelFragment::onFeedNotificationHeaderComponent()"); + headerComponent.setOnLeftButtonClickListener(v -> shouldActivityFinish()); + viewModel.onChannelUpdated().observe(getViewLifecycleOwner(), headerComponent::notifyChannelChanged); + } + + /** + * Called to bind events to the FeedNotificationListComponent. This is called from {@link #onBeforeReady(ReadyStatus, FeedNotificationChannelModule, FeedNotificationChannelViewModel)} regardless of the value of {@link ReadyStatus}. + * + * @param listComponent The component to which the event will be bound + * @param viewModel A view model that provides the data needed for the fragment + * @param channel The {@code FeedChannel} that contains the data needed for this fragment + * @since 3.5.0 + */ + protected void onBindNotificationListComponent(@NonNull FeedNotificationListComponent listComponent, @NonNull FeedNotificationChannelViewModel viewModel, @Nullable FeedChannel channel) { + Logger.d(">> FeedNotificationChannelFragment::onBindFeedNotificationListComponent()"); + listComponent.setOnMessageTemplateActionHandler(actionHandler != null ? actionHandler : this::handleAction); + listComponent.setOnTooltipClickListener(v -> listComponent.scrollToFirst()); + + viewModel.onChannelUpdated().observe(getViewLifecycleOwner(), listComponent::notifyChannelChanged); + viewModel.getMessageList().observeAlways(getViewLifecycleOwner(), messageData -> { + Logger.d("++ message data = %s", messageData); + if (!isFragmentAlive() || channel == null) return; + + final String eventSource = messageData.getTraceName(); + listComponent.notifyDataSetChanged(messageData.getMessages(), channel, notification -> { + if (!isFragmentAlive() || eventSource == null) return; + + switch (eventSource) { + case StringSet.EVENT_MESSAGE_RECEIVED: + listComponent.notifyNewNotificationReceived(); + break; + case StringSet.ACTION_INIT_FROM_REMOTE: + case StringSet.MESSAGE_CHANGELOG: + case StringSet.MESSAGE_FILL: + listComponent.notifyMessagesFilled(); + break; + default: + break; + } + }); + }); + } + + /** + * Called to bind events to the StatusComponent. This is called from {@link #onBeforeReady(ReadyStatus, FeedNotificationChannelModule, FeedNotificationChannelViewModel)} regardless of the value of {@link ReadyStatus}. + * + * @param statusComponent The component to which the event will be bound + * @param viewModel A view model that provides the data needed for the fragment + * @param channel The {@code FeedChannel} that contains the data needed for this fragment + * @since 3.5.0 + */ + protected void onBindStatusComponent(@NonNull StatusComponent statusComponent, @NonNull FeedNotificationChannelViewModel viewModel, @Nullable FeedChannel channel) { + Logger.d(">> FeedNotificationChannelFragment::onBindStatusComponent()"); + statusComponent.setOnActionButtonClickListener(v -> { + statusComponent.notifyStatusChanged(StatusFrameView.Status.LOADING); + shouldAuthenticate(); + }); + viewModel.getStatusFrame().observe(getViewLifecycleOwner(), statusComponent::notifyStatusChanged); + } + + private void handleAction(@NonNull View view, @NonNull Action action, @NonNull BaseMessage message) { + switch (action.getType()) { + case StringSet.web: + handleWebAction(view, action, message); + break; + case StringSet.custom: + handleCustomAction(view, action, message); + break; + default: + break; + } + } + + /** + * If an Action is registered in a specific view, it is called when a click event occurs. + * + * @param action the registered Action data + * @param message a clicked message + * @since 3.5.0 + */ + protected void handleWebAction(@NonNull View view, @NonNull Action action, @NonNull BaseMessage message) { + Logger.d(">> FeedNotificationChannelFragment::handleWebAction() action=%s", action); + final Intent intent = IntentUtils.getWebViewerIntent(action.getData()); + try { + startActivity(intent); + } catch (ActivityNotFoundException e) { + Logger.e(e); + } + } + + /** + * If an Action is registered in a specific view, it is called when a click event occurs. + * + * @param action the registered Action data + * @param message a clicked message + * @since 3.5.0 + */ + protected void handleCustomAction(@NonNull View view, @NonNull Action action, @NonNull BaseMessage message) { + Logger.d(">> FeedNotificationChannelFragment::handleCustomAction() action=%s", action); + try { + final String data = action.getData(); + if (TextUtils.isNotEmpty(data)) { + final Uri uri = Uri.parse(data); + Logger.d("++ uri = %s", uri); + final String scheme = uri.getScheme(); + Logger.d("++ scheme=%s", scheme); + Intent intent = new Intent(Intent.ACTION_VIEW, uri); + boolean hasIntent = IntentUtils.hasIntent(requireContext(), intent); + if (!hasIntent) { + final String alterData = action.getAlterData(); + intent = new Intent(Intent.ACTION_VIEW, Uri.parse(alterData)); + } + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + startActivity(intent); + } + } catch (Exception e) { + Logger.w(e); + } + } + + /** + * Request refreshing the message list. + * Renews the channel and updates the last read time value together. + * + * @since 3.5.0 + */ + public void updateLastReadTimeOnCurrentChannel() { + Logger.d(">> FeedNotificationChannelFragment::updateLastReadTimeOnCurrentChannel()"); + if (!isFragmentAlive()) return; + final FeedChannel channel = getViewModel().getChannel(); + if (channel != null) { + final FeedNotificationListComponent listComponent = getModule().getNotificationListComponent(); + listComponent.notifyLastSeenUpdated(channel.getMyLastRead()); + } + } + + /** + * It will be called when the loading dialog needs displaying. + * + * @return True if the callback has consumed the event, false otherwise. + * @since 3.5.0 + */ + protected boolean shouldShowLoadingDialog() { + return getModule().shouldShowLoadingDialog(); + } + + /** + * It will be called when the loading dialog needs dismissing. + * + * @since 3.5.0 + */ + protected void shouldDismissLoadingDialog() { + getModule().shouldDismissLoadingDialog(); + } + + private synchronized void loadInitial() { + getViewModel().loadInitial(Long.MAX_VALUE); + } + + /** + * Returns the URL of the channel with the required data to use this fragment. + * + * @return The URL of a channel this fragment is currently associated with + * @since 3.5.0 + */ + @NonNull + protected String getChannelUrl() { + final Bundle args = getArguments() == null ? new Bundle() : getArguments(); + return args.getString(StringSet.KEY_CHANNEL_URL, ""); + } + + public static class Builder { + @NonNull + private final Bundle bundle; + @Nullable + private FeedNotificationChannelFragment customFragment; + @Nullable + private OnNotificationTemplateActionHandler actionHandler; + @Nullable + private MessageListParams params; + + /** + * Constructor + * + * @param channelUrl the url of the channel will be implemented. + * @since 3.5.0 + */ + public Builder(@NonNull String channelUrl) { + this(channelUrl, SendbirdUIKit.getDefaultThemeMode()); + } + + /** + * Constructor + * + * @param channelUrl the url of the channel will be implemented. + * @param themeMode {@link SendbirdUIKit.ThemeMode} + * @since 3.5.0 + */ + public Builder(@NonNull String channelUrl, @NonNull SendbirdUIKit.ThemeMode themeMode) { + this(channelUrl, themeMode.getResId()); + } + + /** + * Constructor + * + * @param channelUrl the url of the channel will be implemented. + * @param customThemeResId the resource identifier for custom theme. + * @since 3.5.0 + */ + public Builder(@NonNull String channelUrl, @StyleRes int customThemeResId) { + bundle = new Bundle(); + bundle.putInt(StringSet.KEY_THEME_RES_ID, customThemeResId); + bundle.putString(StringSet.KEY_CHANNEL_URL, channelUrl); + } + + /** + * Sets arguments to this fragment. + * + * @param args the arguments supplied when the fragment was instantiated. + * @return This Builder object to allow for chaining of calls to set methods. + * @since 3.5.0 + */ + @NonNull + public Builder withArguments(@NonNull Bundle args) { + this.bundle.putAll(args); + return this; + } + + /** + * Sets the click listener on the message template view clicked. + * Sets the click listener when the view component that has {@link com.sendbird.uikit.model.Action} is clicked + * + * @param handler The callback that will run. + * @return This Builder object to allow for chaining of calls to set methods. + * @since 3.5.0 + */ + @NonNull + public Builder setOnMessageTemplateActionHandler(@NonNull OnNotificationTemplateActionHandler handler) { + this.actionHandler = handler; + return this; + } + + /** + * Sets whether the header is used. + * + * @param useHeader true if the header is used, false otherwise. + * @return This Builder object to allow for chaining of calls to set methods. + * @since 3.5.0 + */ + @NonNull + public Builder setUseHeader(boolean useHeader) { + bundle.putBoolean(StringSet.KEY_USE_HEADER, useHeader); + return this; + } + + /** + * Sets the custom fragment. It must inherit {@link FeedNotificationChannelFragment}. + * + * @param fragment custom fragment. + * @return This Builder object to allow for chaining of calls to set methods. + * @since 3.5.0 + */ + @NonNull + public Builder setCustomFragment(T fragment) { + this.customFragment = fragment; + return this; + } + + /** + * Sets the notification list params for this channel. + * The reverse property in the MessageListParams are used in the UIKit. Even though you set that property it will be ignored. + * + * @param params The MessageListParams instance that you want to use. + * @return This Builder object to allow for chaining of calls to set methods. + * @since 3.5.0 + */ + @NonNull + public Builder setNotificationListParams(@NonNull MessageListParams params) { + this.params = params; + return this; + } + + /** + * Creates an {@link FeedNotificationChannelFragment} with the arguments supplied to this + * builder. + * + * @return The {@link FeedNotificationChannelFragment} applied to the {@link Bundle}. + * @since 3.5.0 + */ + @NonNull + public FeedNotificationChannelFragment build() { + FeedNotificationChannelFragment fragment = customFragment != null ? customFragment : new FeedNotificationChannelFragment(); + fragment.setArguments(bundle); + fragment.params = params; + fragment.actionHandler = actionHandler; + return fragment; + } + } +} diff --git a/uikit/src/main/java/com/sendbird/uikit/fragments/UIKitFragmentFactory.java b/uikit/src/main/java/com/sendbird/uikit/fragments/UIKitFragmentFactory.java index 2940deb5..128c4eeb 100644 --- a/uikit/src/main/java/com/sendbird/uikit/fragments/UIKitFragmentFactory.java +++ b/uikit/src/main/java/com/sendbird/uikit/fragments/UIKitFragmentFactory.java @@ -397,4 +397,32 @@ public Fragment newMessageThreadFragment(@NonNull String channelUrl, @NonNull Ba .withArguments(args) .build(); } + + /** + * Returns the FeedNotificationChannelFragment. + * + * @param args the arguments supplied when the fragment was instantiated. + * @return The {@link FeedNotificationChannelFragment} + * @since 3.5.0 + */ + @NonNull + public Fragment newFeedNotificationChannelFragment(@NonNull String channelUrl, @NonNull Bundle args) { + return new FeedNotificationChannelFragment.Builder(channelUrl) + .withArguments(args) + .build(); + } + + /** + * Returns the ChatNotificationChannelFragment. + * + * @param args the arguments supplied when the fragment was instantiated. + * @return The {@link FeedNotificationChannelFragment} + * @since 3.5.0 + */ + @NonNull + public Fragment newChatNotificationChannelFragment(@NonNull String channelUrl, @NonNull Bundle args) { + return new ChatNotificationChannelFragment.Builder(channelUrl) + .withArguments(args) + .build(); + } } diff --git a/uikit/src/main/java/com/sendbird/uikit/interfaces/OnNotificationTemplateActionHandler.java b/uikit/src/main/java/com/sendbird/uikit/interfaces/OnNotificationTemplateActionHandler.java new file mode 100644 index 00000000..3640bd19 --- /dev/null +++ b/uikit/src/main/java/com/sendbird/uikit/interfaces/OnNotificationTemplateActionHandler.java @@ -0,0 +1,25 @@ +package com.sendbird.uikit.interfaces; + +import android.view.View; + +import androidx.annotation.NonNull; + +import com.sendbird.android.message.BaseMessage; +import com.sendbird.uikit.model.Action; + +/** + * Interface definition for a callback to be invoked when a item is invoked with an event. + * + * @since 3.5.0 + */ +public interface OnNotificationTemplateActionHandler { + /** + * If an Action is registered in a specific view, it is called when a click event occurs. + * + * @param view the view that was clicked. + * @param action the registered Action data + * @param message the clicked message + * @since 3.5.0 + */ + void onHandleAction(@NonNull View view, @NonNull Action action, @NonNull BaseMessage message); +} diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/extensions/MessageExtensions.kt b/uikit/src/main/java/com/sendbird/uikit/internal/extensions/MessageExtensions.kt index ff26c221..03f272fb 100644 --- a/uikit/src/main/java/com/sendbird/uikit/internal/extensions/MessageExtensions.kt +++ b/uikit/src/main/java/com/sendbird/uikit/internal/extensions/MessageExtensions.kt @@ -2,4 +2,4 @@ package com.sendbird.uikit.internal.extensions import com.sendbird.android.message.BaseMessage -fun BaseMessage.hasParentMessage() = parentMessageId != 0L +internal fun BaseMessage.hasParentMessage() = parentMessageId != 0L diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/extensions/Resources.kt b/uikit/src/main/java/com/sendbird/uikit/internal/extensions/Resources.kt new file mode 100644 index 00000000..2baffe70 --- /dev/null +++ b/uikit/src/main/java/com/sendbird/uikit/internal/extensions/Resources.kt @@ -0,0 +1,21 @@ +package com.sendbird.uikit.internal.extensions + +import android.content.res.Resources +import org.json.JSONException +import org.json.JSONObject + +// int value is a pure number value. For example it makes 10 and 10DP equal. +internal fun Resources.intToDp(value: Int): Int { + return (value * displayMetrics.density + 0.5f).toInt() +} + +@Throws(JSONException::class) +fun JSONObject.toStringMap(): Map { + val map = mutableMapOf() + val keysItr: Iterator = this.keys() + while (keysItr.hasNext()) { + val key = keysItr.next() + map[key] = this.getString(key) + } + return map +} diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/extensions/Utils.kt b/uikit/src/main/java/com/sendbird/uikit/internal/extensions/Utils.kt new file mode 100644 index 00000000..91837b8e --- /dev/null +++ b/uikit/src/main/java/com/sendbird/uikit/internal/extensions/Utils.kt @@ -0,0 +1,12 @@ +package com.sendbird.uikit.internal.extensions + +import android.os.Handler +import android.os.Looper + +private val uiThreadHandler by lazy { Handler(Looper.getMainLooper()) } + +internal fun T?.runOnUiThread(block: (T) -> Unit) { + if (this != null) { + uiThreadHandler.post { block(this) } + } +} diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/extensions/ViewExtensions.kt b/uikit/src/main/java/com/sendbird/uikit/internal/extensions/ViewExtensions.kt index 91d1cf86..d4cb397a 100644 --- a/uikit/src/main/java/com/sendbird/uikit/internal/extensions/ViewExtensions.kt +++ b/uikit/src/main/java/com/sendbird/uikit/internal/extensions/ViewExtensions.kt @@ -2,15 +2,27 @@ package com.sendbird.uikit.internal.extensions import android.annotation.SuppressLint import android.content.Context +import android.content.res.ColorStateList import android.graphics.drawable.Drawable +import android.graphics.drawable.RippleDrawable import android.os.Build +import android.util.TypedValue +import android.view.View import android.widget.EditText +import android.widget.ImageView import android.widget.TextView import androidx.core.content.ContextCompat +import com.bumptech.glide.Glide +import com.bumptech.glide.load.engine.DiskCacheStrategy +import com.bumptech.glide.load.resource.bitmap.RoundedCorners +import com.bumptech.glide.request.RequestOptions +import com.bumptech.glide.request.target.CustomTarget +import com.bumptech.glide.request.transition.Transition +import com.sendbird.uikit.R import com.sendbird.uikit.consts.StringSet @Suppress("DEPRECATION") -fun TextView.setAppearance(context: Context, res: Int) { +internal fun TextView.setAppearance(context: Context, res: Int) { if (Build.VERSION.SDK_INT < 23) { setTextAppearance(context, res) } else { @@ -18,14 +30,14 @@ fun TextView.setAppearance(context: Context, res: Int) { } } -fun EditText.setCursorDrawable(context: Context, res: Int) { +internal fun EditText.setCursorDrawable(context: Context, res: Int) { ContextCompat.getDrawable(context, res)?.let { setCursorDrawable(it) } } @SuppressLint("DiscouragedPrivateApi") -fun EditText.setCursorDrawable(cursor: Drawable) { +internal fun EditText.setCursorDrawable(cursor: Drawable) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { textCursorDrawable = cursor } else { @@ -37,3 +49,66 @@ fun EditText.setCursorDrawable(cursor: Drawable) { } } } + +internal fun View.loadToBackground(url: String, radius: Int = 0, useRipple: Boolean = false) { + var builder = Glide.with(this) + .asDrawable() + .load(url) + .diskCacheStrategy(DiskCacheStrategy.ALL) + if (radius > 0) { + builder = builder.apply(RequestOptions().transform(RoundedCorners(context.resources.intToDp(radius)))) + } + builder.into(object : CustomTarget() { + override fun onResourceReady(resource: Drawable, transition: Transition?) { + if (useRipple) this@loadToBackground.addRipple(resource) else background = resource + } + + override fun onLoadCleared(placeholder: Drawable?) { + } + }) +} + +internal fun ImageView.load(url: String) { + Glide.with(this) + .asDrawable() + .load(url) + // If the height of the image sets as a warp, it needs to be set to a specific size because it is unnatural when scrolling.(with adjustViewBounds true) + .diskCacheStrategy(DiskCacheStrategy.ALL) + .into(this) +} + +internal fun ImageView.loadCircle(url: String) { + val overrideSize = resources + .getDimensionPixelSize(R.dimen.sb_size_64) + + Glide.with(this) + .load(url) + .override(overrideSize, overrideSize) + .circleCrop() + .diskCacheStrategy(DiskCacheStrategy.ALL) + .into(this) +} + +internal fun View.addRipple() = with(TypedValue()) { + context.theme.resolveAttribute(android.R.attr.colorControlHighlight, this, true) + val color = ContextCompat.getColor(context, resourceId) + this@addRipple.background = createRippleDrawable(color, background) +} + +internal fun View.addRipple(background: Drawable?) = with(TypedValue()) { + context.theme.resolveAttribute(android.R.attr.colorControlHighlight, this, true) + val color = ContextCompat.getColor(context, resourceId) + this@addRipple.background = createRippleDrawable(color, background) +} + +internal fun View.addRipple(pressedColor: Int) = with(TypedValue()) { + this@addRipple.background = createRippleDrawable(pressedColor, background) +} + +private fun createRippleDrawable(pressedColor: Int, backgroundDrawable: Drawable?): RippleDrawable { + return RippleDrawable(getPressedState(pressedColor), backgroundDrawable, null) +} + +private fun getPressedState(pressedColor: Int): ColorStateList { + return ColorStateList(arrayOf(intArrayOf()), intArrayOf(pressedColor)) +} diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/interfaces/Disposable.kt b/uikit/src/main/java/com/sendbird/uikit/internal/interfaces/Disposable.kt new file mode 100644 index 00000000..1a92c3dc --- /dev/null +++ b/uikit/src/main/java/com/sendbird/uikit/internal/interfaces/Disposable.kt @@ -0,0 +1,5 @@ +package com.sendbird.uikit.internal.interfaces + +internal interface Disposable { + fun dispose() +} diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/interfaces/ViewRoundable.kt b/uikit/src/main/java/com/sendbird/uikit/internal/interfaces/ViewRoundable.kt new file mode 100644 index 00000000..d58ecd99 --- /dev/null +++ b/uikit/src/main/java/com/sendbird/uikit/internal/interfaces/ViewRoundable.kt @@ -0,0 +1,10 @@ +package com.sendbird.uikit.internal.interfaces + +import android.graphics.Color +import androidx.annotation.ColorInt + +internal interface ViewRoundable { + var radius: Float + fun setRadiusIntSize(radius: Int) + fun setBorder(borderWidth: Int = 0, @ColorInt borderColor: Int = Color.TRANSPARENT) +} diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/model/ExtendedMessageType.kt b/uikit/src/main/java/com/sendbird/uikit/internal/model/ExtendedMessageType.kt new file mode 100644 index 00000000..34144b0b --- /dev/null +++ b/uikit/src/main/java/com/sendbird/uikit/internal/model/ExtendedMessageType.kt @@ -0,0 +1,36 @@ +package com.sendbird.uikit.internal.model + +import com.sendbird.android.channel.ChannelType +import com.sendbird.android.message.BaseMessage +import com.sendbird.uikit.activities.viewholder.MessageType +import com.sendbird.uikit.internal.extensions.toStringMap +import com.sendbird.uikit.internal.model.template_messages.KeySet +import org.json.JSONObject + +internal enum class ExtendedMessageType(val value: String) { + Notification("0"); + + companion object { + @JvmStatic + fun from(message: BaseMessage): MessageType? { + val subType = message.extendedMessage[KeySet.sub_type] + val subData = message.extendedMessage[KeySet.sub_data] + return if (subData != null && subType != null) { + val subDataMap = JSONObject(subData).toStringMap() + val channelType = subDataMap[KeySet.channel_type] + + ExtendedMessageType.values().firstOrNull { it.value == subType }?.let { + when (it) { + Notification -> { + if (channelType == ChannelType.FEED.value) { + MessageType.VIEW_TYPE_FEED_NOTIFICATION + } else { + MessageType.VIEW_TYPE_CHAT_NOTIFICATION + } + } + } + } + } else null + } + } +} diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/model/NotificationDiffCallback.kt b/uikit/src/main/java/com/sendbird/uikit/internal/model/NotificationDiffCallback.kt new file mode 100644 index 00000000..2c9c9061 --- /dev/null +++ b/uikit/src/main/java/com/sendbird/uikit/internal/model/NotificationDiffCallback.kt @@ -0,0 +1,85 @@ +package com.sendbird.uikit.internal.model + +import androidx.recyclerview.widget.DiffUtil +import com.sendbird.android.message.BaseMessage + +internal class NotificationDiffCallback( + private val oldMessageList: List, + private val newMessageList: List, + private val oldLastSeenAt: Long = 0, + private val newLastSeenAt: Long = 0 +) : DiffUtil.Callback() { + /** + * Returns the size of the old list. + * + * @return The size of the old list. + */ + override fun getOldListSize(): Int = oldMessageList.size + + /** + * Returns the size of the new list. + * + * @return The size of the new list. + */ + override fun getNewListSize(): Int = newMessageList.size + + /** + * Called by the DiffUtil to decide whether two object represent the same Item. + * + * + * For example, if your items have unique ids, this method should check their id equality. + * + * @param oldItemPosition The position of the item in the old list + * @param newItemPosition The position of the item in the new list + * @return True if the two items represent the same object or false if they are different. + */ + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + val oldMessage = oldMessageList[oldItemPosition] + val newMessage = newMessageList[newItemPosition] + return oldMessage.messageId == newMessage.messageId + } + + /** + * Called by the DiffUtil when it wants to check whether two items have the same data. + * DiffUtil uses this information to detect if the contents of an item has changed. + * + * + * DiffUtil uses this method to check equality instead of [Object.equals] + * so that you can change its behavior depending on your UI. + * For example, if you are using DiffUtil with a + * [RecyclerView.Adapter], you should + * return whether the items' visual representations are the same. + * + * + * This method is called only if [.areItemsTheSame] returns + * `true` for these items. + * + * @param oldItemPosition The position of the item in the old list + * @param newItemPosition The position of the item in the new list which replaces the + * oldItem + * @return True if the contents of the items are the same or false if they are different. + */ + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + val oldMessage = oldMessageList[oldItemPosition] + val newMessage = newMessageList[newItemPosition] + + if (oldMessage.customType != newMessage.customType) { + return false + } + + if (oldMessage.createdAt != newMessage.createdAt) { + return false + } + + if (oldMessage.updatedAt != newMessage.updatedAt) { + return false + } + + val prevIsNew: Boolean = oldMessage.createdAt > oldLastSeenAt + val currentIsNew: Boolean = newMessage.createdAt > newLastSeenAt + if (prevIsNew != currentIsNew) { + return false + } + return true + } +} diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/model/VoicePlayer.kt b/uikit/src/main/java/com/sendbird/uikit/internal/model/VoicePlayer.kt index 740c343f..380693eb 100644 --- a/uikit/src/main/java/com/sendbird/uikit/internal/model/VoicePlayer.kt +++ b/uikit/src/main/java/com/sendbird/uikit/internal/model/VoicePlayer.kt @@ -11,6 +11,7 @@ import androidx.annotation.UiThread import com.sendbird.android.exception.SendbirdException import com.sendbird.android.message.FileMessage import com.sendbird.uikit.interfaces.OnResultHandler +import com.sendbird.uikit.internal.extensions.runOnUiThread import com.sendbird.uikit.log.Logger import com.sendbird.uikit.utils.ClearableScheduledExecutorService import com.sendbird.uikit.utils.FileUtils @@ -200,12 +201,6 @@ internal class VoicePlayer(val key: String) { }, 0, 100, TimeUnit.MILLISECONDS) } - private fun T?.runOnUiThread(block: (T) -> Unit) { - if (this != null) { - uiThreadHandler.post { block(this) } - } - } - @Synchronized fun dispose() { Logger.i("VoicePlayer::dispose()") diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/model/notifications/NotificationChannelTheme.kt b/uikit/src/main/java/com/sendbird/uikit/internal/model/notifications/NotificationChannelTheme.kt new file mode 100644 index 00000000..ee21756a --- /dev/null +++ b/uikit/src/main/java/com/sendbird/uikit/internal/model/notifications/NotificationChannelTheme.kt @@ -0,0 +1,95 @@ +@file:UseSerializers(CSVColorIntAsStringSerializer::class) +package com.sendbird.uikit.internal.model.notifications + +import com.sendbird.uikit.internal.model.serializer.CSVColorIntAsStringSerializer +import com.sendbird.uikit.internal.model.template_messages.KeySet +import com.sendbird.uikit.internal.singleton.NotificationParser +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.UseSerializers + + +@Serializable +internal data class NotificationChannelSettings( + @SerialName(KeySet.updated_at) + val updatedAt: Long, + @SerialName(KeySet.theme_mode) + val themeMode: NotificationThemeMode, + val themes: List +) { + companion object { + @JvmStatic + fun fromJson(jsonStr: String): NotificationChannelSettings { + return NotificationParser.fromJson(jsonStr) + } + } + + override fun toString(): String { + return NotificationParser.toJsonString(this) + } + + fun getThemeOrNull(): NotificationChannelTheme? { + return themes.firstOrNull() + } +} + +@Serializable +internal data class NotificationChannelTheme( + val key: String, + @SerialName(KeySet.created_at) + val createdAt: Long, + @SerialName(KeySet.updated_at) + val updatedAt: Long, + @SerialName(KeySet.notification) + val notificationTheme: NotificationTheme, + @SerialName(KeySet.list) + val listTheme: NotificationListTheme, + @SerialName(KeySet.header) + val headerTheme: NotificationHeaderTheme +) + +@Serializable +internal data class NotificationTheme( + val radius: Int = 0, + val backgroundColor: CSVColor, + val unreadIndicatorColor: CSVColor, + val category: FontStyle, + val sentAt: FontStyle, + val pressedColor: CSVColor +) + +@Serializable +internal data class NotificationListTheme( + val backgroundColor: CSVColor, + val tooltip: TooltipStyle, + val timeline: TimelineStyle +) + +@Serializable +internal data class NotificationHeaderTheme( + val textSize: Int, + val textColor: CSVColor, + val buttonIconTintColor: CSVColor, + val backgroundColor: CSVColor, + val lineColor: CSVColor +) + +@Serializable +internal data class FontStyle( + val textSize: Int, + val textColor: CSVColor +) + +@Serializable +internal data class TooltipStyle( + val backgroundColor: CSVColor, + val textColor: CSVColor +) + +@Serializable +internal data class TimelineStyle( + val backgroundColor: CSVColor, + val textColor: CSVColor +) + + diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/model/notifications/NotificationCommon.kt b/uikit/src/main/java/com/sendbird/uikit/internal/model/notifications/NotificationCommon.kt new file mode 100644 index 00000000..726f9366 --- /dev/null +++ b/uikit/src/main/java/com/sendbird/uikit/internal/model/notifications/NotificationCommon.kt @@ -0,0 +1,53 @@ +package com.sendbird.uikit.internal.model.notifications + +import android.graphics.Color +import com.sendbird.uikit.SendbirdUIKit +import com.sendbird.uikit.internal.model.template_messages.KeySet +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException + +@Serializable +internal enum class NotificationThemeMode(val value: Int) { + + @SerialName(KeySet.light) + Light(0), + + @SerialName(KeySet.dark) + Dark(1), + + @SerialName(KeySet.default) + Default(2) +} + + +@Serializable +internal data class CSVColor( + private val color: String +) { + fun getColor(themeMode: NotificationThemeMode): Int { + return Color.parseColor(getColorHexString(themeMode)) + } + + @JvmOverloads + fun getColorHexString(themeMode: NotificationThemeMode? = null): String { + return themeMode?.let { + val values = color.split(",") + if (values.isEmpty()) { + throw SerializationException("color value must have value") + } + if (values.size == 1) { + return values[0] + } + var currentTheme = themeMode + if (themeMode == NotificationThemeMode.Default) { + // follow UIKit theme + currentTheme = when (SendbirdUIKit.getDefaultThemeMode()) { + SendbirdUIKit.ThemeMode.Light -> NotificationThemeMode.Light + SendbirdUIKit.ThemeMode.Dark -> NotificationThemeMode.Dark + } + } + return values[currentTheme.value] + } ?: color + } +} diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/model/notifications/NotificationConfig.kt b/uikit/src/main/java/com/sendbird/uikit/internal/model/notifications/NotificationConfig.kt new file mode 100644 index 00000000..db8af929 --- /dev/null +++ b/uikit/src/main/java/com/sendbird/uikit/internal/model/notifications/NotificationConfig.kt @@ -0,0 +1,22 @@ +package com.sendbird.uikit.internal.model.notifications + +import com.sendbird.uikit.interfaces.OnNotificationTemplateActionHandler + +internal class NotificationConfig( + val themeMode: NotificationThemeMode, + val theme: NotificationChannelTheme +) { + var onMessageTemplateActionHandler: OnNotificationTemplateActionHandler? = null + companion object { + @JvmStatic + fun from(notificationChannelSettings: NotificationChannelSettings?): NotificationConfig? { + return notificationChannelSettings?.let { + NotificationConfig( + notificationChannelSettings.themeMode, + notificationChannelSettings.themes.first().copy() + ) + } + } + } +} + diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/model/notifications/NotificationTemplate.kt b/uikit/src/main/java/com/sendbird/uikit/internal/model/notifications/NotificationTemplate.kt new file mode 100644 index 00000000..7eac7210 --- /dev/null +++ b/uikit/src/main/java/com/sendbird/uikit/internal/model/notifications/NotificationTemplate.kt @@ -0,0 +1,73 @@ +package com.sendbird.uikit.internal.model.notifications + +import com.sendbird.uikit.internal.model.serializer.JsonElementToStringSerializer +import com.sendbird.uikit.internal.model.template_messages.KeySet +import com.sendbird.uikit.internal.singleton.NotificationParser +import com.sendbird.uikit.log.Logger +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +internal data class NotificationTemplateList constructor( + val templates: List +) { + companion object { + @JvmStatic + fun fromJson(value: String): NotificationTemplateList { + return NotificationParser.fromJson(value) + } + } +} + +@Serializable +internal data class NotificationTemplate constructor( + @SerialName(KeySet.key) + val templateKey: String, + @SerialName(KeySet.created_at) + val createdAt: Long, + @SerialName(KeySet.updated_at) + val updatedAt: Long, + val name: String? = null, + @SerialName(KeySet.ui_template) + @Serializable(with = JsonElementToStringSerializer::class) + private val _uiTemplate: String, + @SerialName(KeySet.color_variables) + private val _colorVariables: Map +) { + + companion object { + @JvmStatic + fun fromJson(value: String): NotificationTemplate { + return NotificationParser.fromJson(value) + } + } + + fun getTemplateSyntax(variables: Map, themeMode: NotificationThemeMode): String { + val regex = "\\{([^{}]+)\\}".toRegex() + return regex.replace(_uiTemplate) { matchResult -> + val variable = matchResult.groups[1]?.value + var converted = false + + // 1. lookup and convert color variables first + var convertedResult = _colorVariables[variable]?.let { + Logger.i("++ color variable key=$variable, value=$it") + converted = true + val csvColor = CSVColor(it) + csvColor.getColorHexString(themeMode) + } ?: matchResult.value + + // 2. If color variables didn't convert, convert data variables then. + if (!converted && variables.isNotEmpty()) { + convertedResult = variables[variable]?.let { + Logger.i("++ data variable key=$variable, value=$it") + it + } ?: convertedResult + } + convertedResult + } + } + + override fun toString(): String { + return NotificationParser.toJsonString(this) + } +} diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/model/serializer/Serializers.kt b/uikit/src/main/java/com/sendbird/uikit/internal/model/serializer/Serializers.kt new file mode 100644 index 00000000..31bac965 --- /dev/null +++ b/uikit/src/main/java/com/sendbird/uikit/internal/model/serializer/Serializers.kt @@ -0,0 +1,55 @@ +package com.sendbird.uikit.internal.model.serializer + +import android.graphics.Color +import com.sendbird.uikit.internal.model.notifications.CSVColor +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement + +internal object ColorIntAsStringSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ColorInt", PrimitiveKind.STRING) + + override fun deserialize(decoder: Decoder): Int { + val decoded = decoder.decodeString() + // Logger.i("deserialize hex=$decoded") + return Color.parseColor(decoded) + } + + override fun serialize(encoder: Encoder, value: Int) { + val hex = String.format("#%08X", 0xFFFFFFFF and value.toLong()) + // Logger.i("serialize hex=$hex") + encoder.encodeString(hex) + } +} + +internal object CSVColorIntAsStringSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("CSVColor class", PrimitiveKind.STRING) + + override fun deserialize(decoder: Decoder): CSVColor { + val decoded = decoder.decodeString() + // Logger.i("deserialize hex=$decoded") + return CSVColor(decoded) + } + + override fun serialize(encoder: Encoder, value: CSVColor) { + // Logger.i("serialize hex=$hex") + encoder.encodeString(value.getColorHexString()) + } +} + +internal object JsonElementToStringSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("StringJsonSerializer", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: String) { + encoder.encodeSerializableValue(JsonElement.serializer(), Json.parseToJsonElement(value)) + } + + override fun deserialize(decoder: Decoder): String { + return decoder.decodeSerializableValue(JsonElement.serializer()).toString() + } +} diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/model/template_messages/Enums.kt b/uikit/src/main/java/com/sendbird/uikit/internal/model/template_messages/Enums.kt new file mode 100644 index 00000000..1f8458b3 --- /dev/null +++ b/uikit/src/main/java/com/sendbird/uikit/internal/model/template_messages/Enums.kt @@ -0,0 +1,117 @@ +package com.sendbird.uikit.internal.model.template_messages + +import android.graphics.Typeface +import android.view.Gravity +import android.widget.ImageView +import android.widget.LinearLayout +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +internal enum class ViewType { + @SerialName(KeySet.box) + Box, + + @SerialName(KeySet.image) + Image, + + @SerialName(KeySet.textButton) + Button, + + @SerialName(KeySet.imageButton) + ImageButton, + + @SerialName(KeySet.text) + Text + ; + + companion object { + @JvmStatic + fun from(value: String): ViewType { + return values().first { it.name == value } + } + } +} + +@Serializable +internal enum class Orientation(val value: Int) { + @SerialName(KeySet.row) + Row(LinearLayout.HORIZONTAL), + + @SerialName(KeySet.column) + Column(LinearLayout.VERTICAL) +} + +@Serializable +internal enum class Weight(val value: Int) { + @SerialName(KeySet.normal) + Normal(Typeface.NORMAL), + + @SerialName(KeySet.bold) + Bold(Typeface.BOLD) +} + +@Serializable +internal enum class ContentMode(val scaleType: ImageView.ScaleType) { + @SerialName(KeySet.aspectFill) + CenterCrop(ImageView.ScaleType.CENTER_CROP), + + @SerialName(KeySet.aspectFit) + FitCenter(ImageView.ScaleType.FIT_CENTER), + + @SerialName(KeySet.scalesToFill) + FitXY(ImageView.ScaleType.FIT_XY); + + fun toValueAsSerialName(): String { + return when (this) { + CenterCrop -> KeySet.aspectFill + FitCenter -> KeySet.aspectFit + FitXY -> KeySet.scalesToFill + } + } +} + +@Serializable +internal enum class ActionType { + @SerialName(KeySet.web) + Web, + + @SerialName(KeySet.custom) + Custom, + + @SerialName(KeySet.uikit) + Uikit +} + +@Serializable +internal enum class SizeType { + @SerialName(KeySet.fixed) + Fixed, + + @SerialName(KeySet.flex) + Flex +} + +@Serializable +internal enum class VerticalAlign(val value: Int) { + @SerialName(KeySet.top) + Top(Gravity.TOP), + + @SerialName(KeySet.bottom) + Bottom(Gravity.BOTTOM), + + @SerialName(KeySet.center) + Center(Gravity.CENTER_VERTICAL) +} + +@Serializable +internal enum class HorizontalAlign(val value: Int) { + @SerialName(KeySet.left) + Left(Gravity.START), + + @SerialName(KeySet.right) + Right(Gravity.END), + + @SerialName(KeySet.center) + Center(Gravity.CENTER_HORIZONTAL) +} diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/model/template_messages/KeySet.kt b/uikit/src/main/java/com/sendbird/uikit/internal/model/template_messages/KeySet.kt new file mode 100644 index 00000000..50ebc1ca --- /dev/null +++ b/uikit/src/main/java/com/sendbird/uikit/internal/model/template_messages/KeySet.kt @@ -0,0 +1,78 @@ +package com.sendbird.uikit.internal.model.template_messages + +internal object KeySet { + const val version = "version" + const val type = "type" + const val layout = "layout" + const val width = "width" + const val height = "height" + const val value = "value" + const val url = "url" + const val data = "data" + const val alterData = "alterData" + const val align = "align" + const val backgroundColor = "backgroundColor" + const val backgroundImageUrl = "backgroundImageUrl" + const val borderWidth = "borderWidth" + const val borderColor = "borderColor" + const val radius = "radius" + const val items = "items" + const val imageButton = "imageButton" + const val box = "box" + const val image = "image" + const val textButton = "textButton" + const val text = "text" + const val maxTextLines = "maxTextLines" + const val size = "size" + const val color = "color" + const val imageUrl = "imageUrl" + const val weight = "weight" + const val contentMode = "contentMode" + const val viewStyle = "viewStyle" + const val textStyle = "textStyle" + const val buttonStyle = "buttonStyle" + const val imageStyle = "imageStyle" + const val row = "row" + const val column = "column" + const val normal = "normal" + const val bold = "bold" + const val action = "action" + const val aspectFill = "aspectFill" + const val contain = "contain" + const val aspectFit = "aspectFit" + const val scalesToFill = "scalesToFill" + const val web = "web" + const val custom = "custom" + const val uikit = "uikit" + const val fixed = "fixed" + const val flex = "flex" + const val padding = "padding" + const val margin = "margin" + const val top = "top" + const val bottom = "bottom" + const val center = "center" + const val left = "left" + const val right = "right" + const val horizontal = "horizontal" + const val vertical = "vertical" + const val sub_data = "sub_data" + const val sub_type = "sub_type" + + // notifications + const val key = "key" + const val template_key = "template_key" + const val created_at = "created_at" + const val updated_at = "updated_at" + const val ui_template = "ui_template" + const val color_variables = "color_variables" + const val template_variables = "template_variables" + const val light = "light" + const val dark = "dark" + const val default = "default" + const val theme = "theme" + const val theme_mode = "theme_mode" + const val channel_type = "channel_type" + const val notification = "notification" + const val list = "list" + const val header = "header" +} diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/model/template_messages/Params.kt b/uikit/src/main/java/com/sendbird/uikit/internal/model/template_messages/Params.kt new file mode 100644 index 00000000..e750d439 --- /dev/null +++ b/uikit/src/main/java/com/sendbird/uikit/internal/model/template_messages/Params.kt @@ -0,0 +1,234 @@ +package com.sendbird.uikit.internal.model.template_messages + +import android.content.Context +import android.graphics.Color +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import com.sendbird.android.message.BaseMessage +import com.sendbird.uikit.SendbirdUIKit +import com.sendbird.uikit.interfaces.OnNotificationTemplateActionHandler +import com.sendbird.uikit.internal.extensions.intToDp +import com.sendbird.uikit.model.Action +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonClassDiscriminator +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +const val FILL_PARENT = 0 +const val WRAP_CONTENT = 1 + +@Serializable +internal data class ActionData constructor( + val type: ActionType = ActionType.Web, + val data: String, + val alterData: String? = null +) { + fun register( + view: View, + onNotificationTemplateActionHandler: OnNotificationTemplateActionHandler?, + message: BaseMessage + ) { + onNotificationTemplateActionHandler?.let { callback -> + view.setOnClickListener { + callback.onHandleAction(it, Action.from(this@ActionData), message) + } + } + } + + companion object { + fun create( + type: String = KeySet.web, + data: String, + customData: String? = null + ): JsonObject { + return buildJsonObject { + put(KeySet.type, type) + put(KeySet.data, data) + customData?.let { put(KeySet.alterData, data) } + } + } + } +} + +@Serializable +internal data class SizeSpec constructor( + val type: SizeType, + @SerialName(KeySet.value) + private val _value: Int +) { + internal val value: Int + get() = when (type) { + SizeType.Fixed -> _value + SizeType.Flex -> { + when (_value) { + FILL_PARENT -> ViewGroup.LayoutParams.MATCH_PARENT + WRAP_CONTENT -> ViewGroup.LayoutParams.WRAP_CONTENT + else -> _value + } + } + } + + fun getWeight(): Float { + return when (type) { + SizeType.Fixed -> 0F + SizeType.Flex -> { + when (_value) { + FILL_PARENT -> 1F + WRAP_CONTENT -> 0F + else -> 0F + } + } + } + } +} + +@Serializable +internal data class Align constructor( + private val horizontal: HorizontalAlign = HorizontalAlign.Left, + private val vertical: VerticalAlign = VerticalAlign.Top +) { + val gravity: Int + get() = horizontal.value or vertical.value + + companion object { + fun create( + horizontal: String = KeySet.left, + vertical: String = KeySet.top + ): JsonObject { + return buildJsonObject { + put(KeySet.horizontal, horizontal) + put(KeySet.vertical, vertical) + } + } + } +} + +@Serializable +internal data class MetaData constructor( + val pixelWidth: Int, + val pixelHeight: Int +) + +@Serializable +internal data class Params( + val version: Int, + val body: Body +) + +@Serializable +internal data class Body( + val items: List +) + +@OptIn(ExperimentalSerializationApi::class) +@Serializable +@JsonClassDiscriminator(KeySet.type) +internal sealed class ViewParams { + abstract val type: ViewType + abstract val action: ActionData? + abstract val width: SizeSpec + abstract val height: SizeSpec + abstract val viewStyle: ViewStyle + + private fun getWeight(orientation: Orientation): Float { + return when (orientation) { + Orientation.Row -> { + width.getWeight() + } + Orientation.Column -> { + height.getWeight() + } + } + } + + fun applyLayoutParams( + context: Context, + layoutParams: ViewGroup.LayoutParams, + orientation: Orientation + ): ViewGroup.LayoutParams { + val resources = context.resources + layoutParams.width = if (width.type == SizeType.Fixed) resources.intToDp(width.value) else width.value + layoutParams.height = if (height.type == SizeType.Fixed) resources.intToDp(height.value) else height.value + if (layoutParams is LinearLayout.LayoutParams) { + layoutParams.weight = getWeight(orientation) + } + return layoutParams + } +} + +@Serializable +@SerialName(KeySet.box) +internal data class BoxViewParams constructor( + override val type: ViewType, + override val action: ActionData? = null, + override val width: SizeSpec = SizeSpec(SizeType.Flex, FILL_PARENT), + override val height: SizeSpec = SizeSpec(SizeType.Flex, WRAP_CONTENT), + override val viewStyle: ViewStyle = ViewStyle(), + val align: Align = Align(), + @SerialName(KeySet.layout) + val orientation: Orientation = Orientation.Row, + val items: List? = null +) : ViewParams() + +@Serializable +@SerialName(KeySet.text) +internal data class TextViewParams constructor( + override val type: ViewType, + override val action: ActionData? = null, + override val width: SizeSpec = SizeSpec(SizeType.Flex, FILL_PARENT), + override val height: SizeSpec = SizeSpec(SizeType.Flex, WRAP_CONTENT), + override val viewStyle: ViewStyle = ViewStyle(), + val align: Align = Align(), + val text: String, + val maxTextLines: Int? = null, + val textStyle: TextStyle = TextStyle() +) : ViewParams() + +@Serializable +@SerialName(KeySet.image) +internal data class ImageViewParams constructor( + override val type: ViewType, + override val action: ActionData? = null, + override val width: SizeSpec = SizeSpec(SizeType.Flex, FILL_PARENT), + override val height: SizeSpec = SizeSpec(SizeType.Flex, WRAP_CONTENT), + override val viewStyle: ViewStyle = ViewStyle(), + val imageUrl: String, + val metaData: MetaData? = null, + val imageStyle: ImageStyle = ImageStyle() +) : ViewParams() + +@Serializable +@SerialName(KeySet.textButton) +internal data class ButtonViewParams constructor( + override val type: ViewType, + override val action: ActionData? = null, + override val width: SizeSpec = SizeSpec(SizeType.Flex, FILL_PARENT), + override val height: SizeSpec = SizeSpec(SizeType.Flex, WRAP_CONTENT), + override val viewStyle: ViewStyle = ViewStyle(), + val text: String, + val maxTextLines: Int = 1, + val textStyle: TextStyle = TextStyle( + color = when (SendbirdUIKit.getDefaultThemeMode()) { + SendbirdUIKit.ThemeMode.Light -> Color.parseColor("#742ddd") + SendbirdUIKit.ThemeMode.Dark -> Color.parseColor("#c2a9fa") + }, + weight = Weight.Bold + ) +) : ViewParams() + +@Serializable +@SerialName(KeySet.imageButton) +internal data class ImageButtonViewParams constructor( + override val type: ViewType, + override val action: ActionData? = null, + override val width: SizeSpec = SizeSpec(SizeType.Flex, FILL_PARENT), + override val height: SizeSpec = SizeSpec(SizeType.Flex, WRAP_CONTENT), + override val viewStyle: ViewStyle = ViewStyle(), + val imageUrl: String, + val metaData: MetaData? = null, + val imageStyle: ImageStyle = ImageStyle() +) : ViewParams() diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/model/template_messages/Styles.kt b/uikit/src/main/java/com/sendbird/uikit/internal/model/template_messages/Styles.kt new file mode 100644 index 00000000..dcd8fcca --- /dev/null +++ b/uikit/src/main/java/com/sendbird/uikit/internal/model/template_messages/Styles.kt @@ -0,0 +1,118 @@ +package com.sendbird.uikit.internal.model.template_messages + +import android.content.res.ColorStateList +import android.graphics.Color +import android.graphics.drawable.GradientDrawable +import android.util.TypedValue +import android.view.View +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.annotation.ColorInt +import com.sendbird.uikit.internal.extensions.intToDp +import com.sendbird.uikit.internal.extensions.loadToBackground +import com.sendbird.uikit.internal.interfaces.ViewRoundable +import com.sendbird.uikit.internal.model.serializer.ColorIntAsStringSerializer +import kotlinx.serialization.Serializable + +@Serializable +internal data class TextStyle( + val size: Int? = null, + @ColorInt + @Serializable(with = ColorIntAsStringSerializer::class) + val color: Int? = null, + val weight: Weight? = null +) { + fun apply(view: TextView): TextStyle { + size?.let { view.setTextSize(TypedValue.COMPLEX_UNIT_SP, it.toFloat()) } + color?.let { view.setTextColor(it) } + weight?.let { view.setTypeface(view.typeface, it.value) } + return this + } +} + +@Serializable +internal data class ImageStyle( + val contentMode: ContentMode? = null, + @Serializable(with = ColorIntAsStringSerializer::class) + val tintColor: Int? = null, +) { + fun apply(view: ImageView): ImageStyle { + contentMode?.let { view.scaleType = it.scaleType } + tintColor?.let { view.imageTintList = ColorStateList.valueOf(it) } + return this + } +} + +@Serializable +internal data class ViewStyle( + @ColorInt + @Serializable(with = ColorIntAsStringSerializer::class) + val backgroundColor: Int? = null, + val backgroundImageUrl: String? = null, + val borderWidth: Int? = null, + @ColorInt + @Serializable(with = ColorIntAsStringSerializer::class) + val borderColor: Int? = null, + val radius: Int? = null, + val margin: Margin? = null, + val padding: Padding? = null +) { + fun apply(view: View, useRipple: Boolean = false): ViewStyle { + val resources = view.context.resources + backgroundImageUrl?.let { view.loadToBackground(it, radius ?: 0, useRipple) } + if (backgroundColor != null || (borderWidth != null && borderWidth > 0)) { + view.background = GradientDrawable().apply { + backgroundColor?.let { setColor(it) } + cornerRadius = resources.intToDp(radius ?: 0).toFloat() + } + } + + margin?.apply(view) + padding?.apply(view) + + if (view is ViewRoundable) { + radius?.let { view.setRadiusIntSize(it) } + borderWidth?.let { view.setBorder(borderWidth, borderColor ?: Color.TRANSPARENT) } + } + return this + } +} + +@Serializable +internal data class Margin constructor( + val top: Int = 0, + val bottom: Int = 0, + val left: Int = 0, + val right: Int = 0 +) { + fun apply(view: View) { + val resources = view.context.resources + val layoutParams = view.layoutParams as LinearLayout.LayoutParams + layoutParams.setMargins( + resources.intToDp(left), + resources.intToDp(top), + resources.intToDp(right), + resources.intToDp(bottom) + ) + view.layoutParams = layoutParams + } +} + +@Serializable +internal data class Padding constructor( + val top: Int = 0, + val bottom: Int = 0, + val left: Int = 0, + val right: Int = 0 +) { + fun apply(view: View) { + val resources = view.context.resources + view.setPadding( + resources.intToDp(left), + resources.intToDp(top), + resources.intToDp(right), + resources.intToDp(bottom) + ) + } +} diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/model/template_messages/Template.kt b/uikit/src/main/java/com/sendbird/uikit/internal/model/template_messages/Template.kt new file mode 100644 index 00000000..6de2463d --- /dev/null +++ b/uikit/src/main/java/com/sendbird/uikit/internal/model/template_messages/Template.kt @@ -0,0 +1,175 @@ +package com.sendbird.uikit.internal.model.template_messages + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +internal object Template { + private fun createImage( + action: JsonObject? = null, + width: JsonObject? = null, + height: JsonObject? = null, + viewStyle: JsonObject? = null, + imageUrl: String, + imageStyle: JsonObject? = null, + ): JsonObject { + return buildJsonObject { + put(KeySet.type, KeySet.image) + action?.let { put(KeySet.action, it) } + width?.let { put(KeySet.width, it) } + height?.let { put(KeySet.height, it) } + viewStyle?.let { put(KeySet.viewStyle, it) } + put(KeySet.imageUrl, imageUrl) + imageStyle?.let { put(KeySet.imageStyle, it) } + } + } + + private fun createButton( + action: JsonObject? = null, + width: JsonObject? = null, + height: JsonObject? = null, + viewStyle: JsonObject? = null, + text: String, + maxTextLines: Int? = null, + textStyle: JsonObject? = null, + ): JsonObject { + return buildJsonObject { + put(KeySet.type, KeySet.textButton) + action?.let { put(KeySet.action, it) } + width?.let { put(KeySet.width, it) } + height?.let { put(KeySet.height, it) } + viewStyle?.let { put(KeySet.viewStyle, it) } + put(KeySet.text, text) + maxTextLines?.let { put(KeySet.maxTextLines, it) } + textStyle?.let { put(KeySet.textStyle, it) } + } + } + + private fun createImageButton( + action: JsonObject? = null, + width: JsonObject? = null, + height: JsonObject? = null, + viewStyle: JsonObject? = null, + imageUrl: String, + imageStyle: JsonObject? = null, + ): JsonObject { + return buildJsonObject { + put(KeySet.type, KeySet.imageButton) + action?.let { put(KeySet.action, it) } + width?.let { put(KeySet.width, it) } + height?.let { put(KeySet.height, it) } + viewStyle?.let { put(KeySet.viewStyle, it) } + put(KeySet.imageUrl, imageUrl) + imageStyle?.let { put(KeySet.imageStyle, it) } + } + } + + private fun createText( + action: JsonObject? = null, + width: JsonObject? = null, + height: JsonObject? = null, + align: JsonObject? = null, + viewStyle: JsonObject? = null, + text: String, + maxTextLines: Int? = null, + textStyle: JsonObject? = null, + ): JsonObject { + return buildJsonObject { + put(KeySet.type, KeySet.text) + action?.let { put(KeySet.action, it) } + width?.let { put(KeySet.width, it) } + height?.let { put(KeySet.height, it) } + align?.let { put(KeySet.align, it) } + viewStyle?.let { put(KeySet.viewStyle, it) } + put(KeySet.text, text) + maxTextLines?.let { put(KeySet.maxTextLines, it) } + textStyle?.let { put(KeySet.textStyle, it) } + } + } + + private fun createBox( + action: JsonObject? = null, + width: JsonObject? = null, + height: JsonObject? = null, + align: JsonObject? = null, + viewStyle: JsonObject? = null, + layout: String? = null, + items: JsonArray + ): JsonObject { + return buildJsonObject { + put(KeySet.type, KeySet.box) + action?.let { put(KeySet.action, it) } + width?.let { put(KeySet.width, it) } + height?.let { put(KeySet.height, it) } + align?.let { put(KeySet.align, it) } + viewStyle?.let { put(KeySet.viewStyle, it) } + layout?.let { put(KeySet.layout, it) } + put(KeySet.items, items) + } + } + + private fun createSize( + type: String, + value: Int + ): JsonObject { + return buildJsonObject { + put(KeySet.type, type) + put(KeySet.value, value) + } + } + + private fun createRect( + top: Int? = null, + bottom: Int? = null, + left: Int? = null, + right: Int? = null, + ): JsonObject { + return buildJsonObject { + top?.let { put(KeySet.top, it) } + bottom?.let { put(KeySet.bottom, it) } + left?.let { put(KeySet.left, it) } + right?.let { put(KeySet.right, it) } + } + } + + private fun createTextStyle( + size: Int? = null, + color: String? = null, + weight: String? = null, + ): JsonObject { + return buildJsonObject { + size?.let { put(KeySet.size, it) } + color?.let { put(KeySet.color, it) } + weight?.let { put(KeySet.weight, it) } + } + } + + private fun createImageStyle( + contentMode: String? = null, + ): JsonObject { + return buildJsonObject { + contentMode?.let { put(KeySet.contentMode, it) } + } + } + + private fun createViewStyle( + backgroundColor: String? = null, + backgroundImageUrl: String? = null, + borderWidth: Int? = null, + borderColor: String? = null, + radius: Int? = null, + margin: JsonObject? = null, + padding: JsonObject? = null + ): JsonObject { + return buildJsonObject { + backgroundColor?.let { put(KeySet.backgroundColor, it) } + backgroundImageUrl?.let { put(KeySet.backgroundImageUrl, it) } + borderWidth?.let { put(KeySet.borderWidth, it) } + borderColor?.let { put(KeySet.borderColor, it) } + radius?.let { put(KeySet.radius, it) } + margin?.let { put(KeySet.margin, it) } + padding?.let { put(KeySet.padding, it) } + } + } +} diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/model/template_messages/TemplateViewGenerator.kt b/uikit/src/main/java/com/sendbird/uikit/internal/model/template_messages/TemplateViewGenerator.kt new file mode 100644 index 00000000..e5a7c314 --- /dev/null +++ b/uikit/src/main/java/com/sendbird/uikit/internal/model/template_messages/TemplateViewGenerator.kt @@ -0,0 +1,126 @@ +package com.sendbird.uikit.internal.model.template_messages + +import android.content.Context +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import com.sendbird.uikit.internal.ui.widgets.Box +import com.sendbird.uikit.internal.ui.widgets.Image +import com.sendbird.uikit.internal.ui.widgets.ImageButton +import com.sendbird.uikit.internal.ui.widgets.Text +import com.sendbird.uikit.internal.ui.widgets.TextButton + +internal typealias ViewLifecycleHandler = (view: View, viewParams: ViewParams) -> Unit + +internal object TemplateViewGenerator { + fun inflateViews( + context: Context, + params: Params, + onViewCreated: ViewLifecycleHandler? = null + ): View { + when (params.version) { + 1 -> { + return LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + params.body.items.forEach { + addView( + generateView(context, it, Orientation.Column, onViewCreated) + ) + } + } + } + else -> { + throw RuntimeException("unsupported version. current version = ${params.version}") + } + } + } + + private fun generateView( + context: Context, + viewParams: ViewParams, + orientation: Orientation, + onViewCreated: ViewLifecycleHandler? = null + ): View { + return when (viewParams) { + is BoxViewParams -> createBoxView(context, viewParams, orientation, onViewCreated) + is ImageViewParams -> createImageView(context, viewParams, orientation, onViewCreated) + is TextViewParams -> createTextView(context, viewParams, orientation, onViewCreated) + is ButtonViewParams -> createButtonView(context, viewParams, orientation, onViewCreated) + is ImageButtonViewParams -> createImageButtonView(context, viewParams, orientation, onViewCreated) + } + } + + private fun createTextView( + context: Context, + params: TextViewParams, + orientation: Orientation, + onViewCreated: ViewLifecycleHandler? = null + ): View { + return Text(context).apply { + onViewCreated?.invoke(this, params) + apply(params, orientation) + } + } + + private fun createImageView( + context: Context, + params: ImageViewParams, + orientation: Orientation, + onViewCreated: ViewLifecycleHandler? = null + ): View { + return Image(context).apply { + onViewCreated?.invoke(this, params) + apply(params, orientation) + } + } + + private fun createButtonView( + context: Context, + params: ButtonViewParams, + orientation: Orientation, + onViewCreated: ViewLifecycleHandler? = null + ): View { + return TextButton(context).apply { + onViewCreated?.invoke(this, params) + apply(params, orientation) + } + } + + private fun createImageButtonView( + context: Context, + params: ImageButtonViewParams, + orientation: Orientation, + onViewCreated: ViewLifecycleHandler? = null + ): View { + return ImageButton(context).apply { + onViewCreated?.invoke(this, params) + apply(params, orientation) + } + } + + private fun createBoxView( + context: Context, + params: BoxViewParams, + orientation: Orientation, + onViewCreated: ViewLifecycleHandler? = null + ): ViewGroup { + return Box(context).apply { + onViewCreated?.invoke(this, params) + apply(params, orientation) + params.items?.forEach { + addView( + generateView( + context, + it, + params.orientation, + onViewCreated + ) + ) + } + } + } +} diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/singleton/BaseSharedPreference.kt b/uikit/src/main/java/com/sendbird/uikit/internal/singleton/BaseSharedPreference.kt new file mode 100644 index 00000000..f80ffbb5 --- /dev/null +++ b/uikit/src/main/java/com/sendbird/uikit/internal/singleton/BaseSharedPreference.kt @@ -0,0 +1,49 @@ +package com.sendbird.uikit.internal.singleton + +import android.content.Context +import android.content.SharedPreferences + +internal class BaseSharedPreference( + context: Context, + fileName: String, + mode: Int = Context.MODE_PRIVATE +) { + + private val preferences: SharedPreferences = context.applicationContext.getSharedPreferences( + fileName, + mode + ) + + fun clearAll() { + preferences.edit().clear().apply() + } + + fun loadAll(predicate: (String) -> Boolean, onEach: (String, Any?) -> Unit) { + val all = preferences.all + + all.entries + .filter { predicate(it.key) } + .forEach { onEach(it.key, it.value) } + } + + fun remove(key: String) { + if (key in preferences) { + preferences.edit().remove(key).apply() + } + } + + fun putString(key: String, value: String) { + preferences.edit().putString(key, value).apply() + } + + fun getString(key: String): String? = preferences.getString(key, null) + + fun optString(key: String, default: String = ""): String = preferences.getString(key, default) ?: default + + fun putLong(key: String, value: Long) { + preferences.edit().putLong(key, value).apply() + } + + fun getLong(key: String): Long = + preferences.getLong(key, 0L) +} diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/singleton/MessageTemplateParser.kt b/uikit/src/main/java/com/sendbird/uikit/internal/singleton/MessageTemplateParser.kt new file mode 100644 index 00000000..36451941 --- /dev/null +++ b/uikit/src/main/java/com/sendbird/uikit/internal/singleton/MessageTemplateParser.kt @@ -0,0 +1,145 @@ +package com.sendbird.uikit.internal.singleton + +import android.graphics.Color +import com.sendbird.android.message.BaseMessage +import com.sendbird.uikit.SendbirdUIKit +import com.sendbird.uikit.internal.model.notifications.NotificationThemeMode +import com.sendbird.uikit.internal.model.template_messages.Body +import com.sendbird.uikit.internal.model.template_messages.BoxViewParams +import com.sendbird.uikit.internal.model.template_messages.KeySet +import com.sendbird.uikit.internal.model.template_messages.Margin +import com.sendbird.uikit.internal.model.template_messages.Orientation +import com.sendbird.uikit.internal.model.template_messages.Padding +import com.sendbird.uikit.internal.model.template_messages.Params +import com.sendbird.uikit.internal.model.template_messages.TextStyle +import com.sendbird.uikit.internal.model.template_messages.TextViewParams +import com.sendbird.uikit.internal.model.template_messages.ViewStyle +import com.sendbird.uikit.internal.model.template_messages.ViewType +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.decodeFromJsonElement +import org.json.JSONObject + +internal object MessageTemplateParser { + private val json by lazy { + Json { + prettyPrint = true + ignoreUnknownKeys = true + + // https://github.com/Kotlin/kotlinx.serialization/blob/master/docs/json.md#coercing-input-values + // coerceInputValues = true + + // https://github.com/Kotlin/kotlinx.serialization/blob/master/docs/json.md#encoding-defaults + // encodeDefaults = true + } + } + + @JvmStatic + fun parseParams(el: JsonElement): Params { + return json.decodeFromJsonElement(el) + } + + @JvmStatic + fun parseParams(jsonStr: String): Params { + return json.decodeFromString(jsonStr) + } + + @JvmStatic + fun parseToMap(jsonStr: String): Map { + return json.decodeFromString(jsonStr) + } + + @JvmStatic + @Throws(Exception::class) + fun parse(jsonTemplate: String): Params { + return when (val version = JSONObject(jsonTemplate).getInt(KeySet.version)) { + 1 -> parseParams(jsonTemplate) + else -> throw RuntimeException("unsupported version. current version = $version") + } + } + + @JvmStatic + fun createDefaultViewParam( + message: BaseMessage, + defaultFallbackTitle: String, + defaultFallbackDescription: String, + themeMode: NotificationThemeMode + ): Params { + val hasFallbackMessage = message.message.isNotEmpty() + val textList = mutableListOf( + TextViewParams( + type = ViewType.Text, + textStyle = TextStyle( + size = 14, + color = getTitleColor(themeMode) + ), + text = message.message.takeIf { it.isNotEmpty() } ?: defaultFallbackTitle + ) + ) + + if (!hasFallbackMessage) { + textList.add( + TextViewParams( + type = ViewType.Text, + textStyle = TextStyle( + size = 14, + color = getDescTextColor(themeMode) + ), + viewStyle = ViewStyle( + margin = Margin( + top = 10 + ) + ), + text = defaultFallbackDescription + ) + ) + } + return Params( + version = 1, + body = Body( + items = listOf( + BoxViewParams( + type = ViewType.Box, + orientation = Orientation.Column, + viewStyle = ViewStyle( + backgroundColor = getBackgroundColor(themeMode), + padding = Padding( + 12, 12, 12, 12 + ), + radius = 8 + ), + items = textList + ), + ) + ) + ) + } + + private fun getBackgroundColor(themeMode: NotificationThemeMode): Int { + val color = when (themeMode) { + NotificationThemeMode.Light -> "#EEEEEE" + NotificationThemeMode.Dark -> "#2C2C2C" + NotificationThemeMode.Default -> if (SendbirdUIKit.getDefaultThemeMode() == SendbirdUIKit.ThemeMode.Light) "#EEEEEE" else "#2C2C2C" + } + return Color.parseColor(color) + } + + private fun getTitleColor(themeMode: NotificationThemeMode): Int { + val color = when (themeMode) { + NotificationThemeMode.Light -> "#E0000000" + NotificationThemeMode.Dark -> "#E0FFFFFF" + NotificationThemeMode.Default -> if (SendbirdUIKit.getDefaultThemeMode() == SendbirdUIKit.ThemeMode.Light) "#E0000000" else "#E0FFFFFF" + } + return Color.parseColor(color) + } + + private fun getDescTextColor(themeMode: NotificationThemeMode): Int { + val color = when (themeMode) { + NotificationThemeMode.Light -> "#70000000" + NotificationThemeMode.Dark -> "#70FFFFFF" + NotificationThemeMode.Default -> if (SendbirdUIKit.getDefaultThemeMode() == SendbirdUIKit.ThemeMode.Light) "#70000000" else "#70FFFFFF" + } + return Color.parseColor(color) + } +} diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/singleton/NotificationChannelManager.kt b/uikit/src/main/java/com/sendbird/uikit/internal/singleton/NotificationChannelManager.kt new file mode 100644 index 00000000..4d4a8af2 --- /dev/null +++ b/uikit/src/main/java/com/sendbird/uikit/internal/singleton/NotificationChannelManager.kt @@ -0,0 +1,104 @@ +package com.sendbird.uikit.internal.singleton + +import android.content.Context +import androidx.annotation.WorkerThread +import com.sendbird.uikit.internal.model.notifications.NotificationChannelSettings +import com.sendbird.uikit.internal.model.notifications.NotificationThemeMode +import com.sendbird.uikit.log.Logger +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicBoolean + +internal object NotificationChannelManager { + private val worker = Executors.newFixedThreadPool(10) + private val isInitialized: AtomicBoolean = AtomicBoolean() + private val loadingKeys: MutableSet = mutableSetOf() + + private lateinit var templateRepository: NotificationTemplateRepository + private lateinit var channelSettingsRepository: NotificationChannelRepository + + @JvmStatic + fun init(context: Context) { + if (isInitialized.getAndSet(true)) return + worker.submit { + templateRepository = NotificationTemplateRepository(context.applicationContext) + channelSettingsRepository = NotificationChannelRepository(context.applicationContext) + }.get() + } + + @JvmStatic + fun getTemplate(key: String, variables: Map, themeMode: NotificationThemeMode): String? { + return templateRepository.getTemplate(key)?.let { + return it.getTemplateSyntax(variables, themeMode).run { + Logger.d("++ key=[$key], template=$this") + this + } + } ?: run { + // If the data is not in the cache, it is not applied in real time even if it is received from the API. + requestTemplate(key) + null + } + } + + @JvmStatic + fun getGlobalNotificationChannelSettings(): NotificationChannelSettings? { + return channelSettingsRepository.settings + } + + @WorkerThread + @JvmStatic + @Throws(Exception::class) + @Synchronized + fun requestTemplateListBlocking(latestToken: String?) { + // 1. check updated time with server. + if (!templateRepository.needToUpdateTemplateList(latestToken)) { + Logger.d("++ skip request template list. no more items to update") + return + } + + // 2. call api + templateRepository.requestTemplateList() + } + + @WorkerThread + @JvmStatic + @Throws(Exception::class) + @Synchronized + fun requestNotificationChannelSettingBlocking(latestUpdatedAt: Long): NotificationChannelSettings { + // 0-1. check from cache + channelSettingsRepository.settings?.let { + if (!channelSettingsRepository.needToUpdate(latestUpdatedAt)) { + Logger.d("++ skip request channel theme settings. no more items to update") + return it + } + } + // 1. call api + return channelSettingsRepository.requestSettings() + } + + private fun requestTemplate(key: String) { + Logger.d(">> NotificationChannelManager::requestTemplate(), key=$key") + // 0. check it already has been requested. + synchronized(loadingKeys) { + if (!loadingKeys.add(key)) return + } + worker.submit { + try { + // 1. call API + templateRepository.requestTemplate(key) + } catch (ignore: Exception) { + } finally { + synchronized(loadingKeys) { + loadingKeys.remove(key) + } + } + } + } + + @JvmStatic + fun dispose() { + templateRepository.dispose() + channelSettingsRepository.dispose() + worker.shutdown() + } +} + diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/singleton/NotificationChannelRepository.kt b/uikit/src/main/java/com/sendbird/uikit/internal/singleton/NotificationChannelRepository.kt new file mode 100644 index 00000000..63413a7a --- /dev/null +++ b/uikit/src/main/java/com/sendbird/uikit/internal/singleton/NotificationChannelRepository.kt @@ -0,0 +1,84 @@ +package com.sendbird.uikit.internal.singleton + +import android.content.Context +import androidx.annotation.WorkerThread +import com.sendbird.android.SendbirdChat +import com.sendbird.android.exception.SendbirdException +import com.sendbird.uikit.internal.interfaces.Disposable +import com.sendbird.uikit.internal.model.notifications.NotificationChannelSettings +import com.sendbird.uikit.log.Logger +import java.util.concurrent.CountDownLatch +import java.util.concurrent.atomic.AtomicReference + +private const val NOTIFICATION_CHANNEL_SETTINGS = "GLOBAL_NOTIFICATION_CHANNEL_THEME" +private const val LAST_UPDATED_CHANNEL_SETTINGS_AT = "LAST_UPDATED_CHANNEL_SETTINGS_AT" +private const val PREFERENCE_FILE_NAME = "com.sendbird.notifications.channel_settings" + +internal class NotificationChannelRepository(context: Context) : Disposable { + private val preferences = BaseSharedPreference(context.applicationContext, PREFERENCE_FILE_NAME) + + private var currentUpdatedAt: Long = 0L + get() { + return if (field > 0) { + field + } else { + field = preferences.getLong(LAST_UPDATED_CHANNEL_SETTINGS_AT) + field + } + } + private set(value) { + if (field != value) { + field = value + preferences.putLong(LAST_UPDATED_CHANNEL_SETTINGS_AT, value) + } + } + var settings: NotificationChannelSettings? = null + private set + + init { + preferences.getString(NOTIFICATION_CHANNEL_SETTINGS)?.let { + this.settings = NotificationChannelSettings.fromJson(it) + } + + } + + fun needToUpdate(latestUpdatedAt: Long): Boolean { + return currentUpdatedAt < latestUpdatedAt || settings == null + } + + @WorkerThread + @Throws(Exception::class) + fun requestSettings(): NotificationChannelSettings { + val latch = CountDownLatch(1) + var error: SendbirdException? = null + val result: AtomicReference = AtomicReference() + SendbirdChat.getGlobalNotificationChannelSetting { globalNotificationChannelSetting, e -> + error = e + try { + globalNotificationChannelSetting?.let { + Logger.i("++ request response Application theme settings : ${it.jsonPayload}") + result.set(NotificationChannelSettings.fromJson(it.jsonPayload)) + } + } catch (parsingError: Throwable) { + error = SendbirdException("notification channel settings response data is not valid", parsingError) + } finally { + latch.countDown() + } + } + latch.await() + error?.let { throw it } + + return result.get().also { + Logger.d("++ currentUpdatedAt=$currentUpdatedAt, response.updatedAt=${it.updatedAt}") + if (it.updatedAt > 0) currentUpdatedAt = it.updatedAt + this.settings = it + preferences.putString(NOTIFICATION_CHANNEL_SETTINGS, it.toString()) + } + } + + override fun dispose() { + currentUpdatedAt = 0L + settings = null + preferences.clearAll() + } +} diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/singleton/NotificationParser.kt b/uikit/src/main/java/com/sendbird/uikit/internal/singleton/NotificationParser.kt new file mode 100644 index 00000000..6e1a7b5e --- /dev/null +++ b/uikit/src/main/java/com/sendbird/uikit/internal/singleton/NotificationParser.kt @@ -0,0 +1,30 @@ +package com.sendbird.uikit.internal.singleton + +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.encodeToJsonElement + +internal object NotificationParser { + private val json by lazy { + Json { + ignoreUnknownKeys = true + } + } + + @JvmStatic + inline fun fromJson(value: String): T { + return json.decodeFromString(value) + } + + @JvmStatic + inline fun toJsonString(value: T): String { + return json.encodeToString(value) + } + + @JvmStatic + inline fun toJsonElement(value: T): JsonElement { + return json.encodeToJsonElement(value) + } +} diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/singleton/NotificationTemplateRepository.kt b/uikit/src/main/java/com/sendbird/uikit/internal/singleton/NotificationTemplateRepository.kt new file mode 100644 index 00000000..a18a0790 --- /dev/null +++ b/uikit/src/main/java/com/sendbird/uikit/internal/singleton/NotificationTemplateRepository.kt @@ -0,0 +1,129 @@ +package com.sendbird.uikit.internal.singleton + +import android.content.Context +import androidx.annotation.WorkerThread +import com.sendbird.android.SendbirdChat +import com.sendbird.android.exception.SendbirdException +import com.sendbird.android.params.NotificationTemplateListParams +import com.sendbird.uikit.internal.interfaces.Disposable +import com.sendbird.uikit.internal.model.notifications.NotificationTemplate +import com.sendbird.uikit.internal.model.notifications.NotificationTemplateList +import com.sendbird.uikit.log.Logger +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CountDownLatch +import java.util.concurrent.atomic.AtomicReference + +private const val TEMPLATE_KEY_PREFIX = "SB_TEMPLATE_" +private const val LAST_UPDATED_TEMPLATE_LIST_TOKEN = "LAST_UPDATED_TEMPLATE_LIST_AT" +private const val PREFERENCE_FILE_NAME = "com.sendbird.notifications.templates" + +internal class NotificationTemplateRepository(context: Context) : Disposable { + private val templateCache: MutableMap = ConcurrentHashMap() + private val preferences = BaseSharedPreference(context.applicationContext, PREFERENCE_FILE_NAME) + private var lastCacheToken: String = "" + get() { + return field.ifEmpty { + field = preferences.optString(LAST_UPDATED_TEMPLATE_LIST_TOKEN) + field + } + } + private set(value) { + if (value != field) { + field = value + preferences.putString(LAST_UPDATED_TEMPLATE_LIST_TOKEN, value) + } + } + + init { + preferences.loadAll({ key -> + key.startsWith(TEMPLATE_KEY_PREFIX) + }, { key, value -> + templateCache[key] = NotificationTemplate.fromJson(value.toString()) + }) + } + + private fun getTemplateKey(key: String) = "${TEMPLATE_KEY_PREFIX}$key" + + @WorkerThread + private fun saveToCache(template: NotificationTemplate) { + Logger.d(">> NotificationTemplateRepository::saveToCache() key=${template.templateKey}") + val key = getTemplateKey(template.templateKey) + templateCache[key] = template + preferences.putString(key, template.toString()) + } + + fun needToUpdateTemplateList(latestUpdatedToken: String?): Boolean { + return lastCacheToken.isEmpty() || lastCacheToken != latestUpdatedToken + } + + fun getTemplate(key: String): NotificationTemplate? { + Logger.d(">> NotificationTemplateRepository::getTemplate() key=$key") + return templateCache[getTemplateKey(key)] + } + + @WorkerThread + @Throws(SendbirdException::class) + fun requestTemplateList(): NotificationTemplateList { + Logger.d(">> NotificationTemplateRepository::requestTemplateList()") + val latch = CountDownLatch(1) + var error: SendbirdException? = null + val result: AtomicReference = AtomicReference() + SendbirdChat.getNotificationTemplateListByToken(lastCacheToken, NotificationTemplateListParams().apply { + limit = 100 + }) { notificationTemplateList, _, token, e -> + error = e + try { + if (!token.isNullOrEmpty()) lastCacheToken = token + val templateList = notificationTemplateList?.let { + NotificationTemplateList.fromJson(it.jsonPayload) + } + result.set(templateList) + } catch (e: Throwable) { + error = SendbirdException("notification template list data is not valid", e) + } finally { + latch.countDown() + } + } + latch.await() + error?.let { throw it } + return result.get().also { + it?.templates?.forEach { template -> + // convert list to map + saveToCache(template) + } + } + } + + @WorkerThread + @Throws(SendbirdException::class) + fun requestTemplate(key: String): NotificationTemplate { + Logger.d(">> NotificationTemplateRepository::requestTemplate() key=$key") + val latch = CountDownLatch(1) + var error: SendbirdException? = null + val result: AtomicReference = AtomicReference() + SendbirdChat.getNotificationTemplate(key) { template, e -> + error = e + try { + template?.let { + Logger.i("++ request response template key=$key : ${it.jsonPayload}") + result.set(NotificationTemplate.fromJson(it.jsonPayload)) + } + } catch (e: Throwable) { + error = SendbirdException("notification template response data is not valid", e) + } finally { + latch.countDown() + } + } + latch.await() + error?.let { throw it } + + // save to cache + return result.get().also { saveToCache(it) } + } + + override fun dispose() { + lastCacheToken = "" + templateCache.clear() + preferences.clearAll() + } +} diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/ui/channels/ChannelPreview.kt b/uikit/src/main/java/com/sendbird/uikit/internal/ui/channels/ChannelPreview.kt index 783d5a2e..b4251add 100644 --- a/uikit/src/main/java/com/sendbird/uikit/internal/ui/channels/ChannelPreview.kt +++ b/uikit/src/main/java/com/sendbird/uikit/internal/ui/channels/ChannelPreview.kt @@ -10,6 +10,7 @@ import android.widget.FrameLayout import android.widget.ImageView import android.widget.TextView import com.sendbird.android.channel.GroupChannel +import com.sendbird.android.message.AdminMessage import com.sendbird.android.message.FileMessage import com.sendbird.android.message.UserMessage import com.sendbird.uikit.R @@ -21,9 +22,7 @@ import com.sendbird.uikit.utils.DrawableUtils import com.sendbird.uikit.utils.MessageUtils internal class ChannelPreview @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyle: Int = R.attr.sb_widget_channel_preview + context: Context, attrs: AttributeSet? = null, defStyle: Int = R.attr.sb_widget_channel_preview ) : FrameLayout(context, attrs, defStyle) { private val coverView: ChannelCoverView private val tvTitle: TextView @@ -59,32 +58,26 @@ internal class ChannelPreview @JvmOverloads constructor( ivFrozen = layout.findViewById(R.id.ivFrozenIcon) ivLastMessageStatus = layout.findViewById(R.id.ivLastMessageStatus) val background = a.getResourceId( - R.styleable.ChannelPreview_sb_channel_preview_background, - R.drawable.selector_rectangle_light + R.styleable.ChannelPreview_sb_channel_preview_background, R.drawable.selector_rectangle_light ) val titleAppearance = a.getResourceId( - R.styleable.ChannelPreview_sb_channel_preview_title_appearance, - R.style.SendbirdSubtitle1OnLight01 + R.styleable.ChannelPreview_sb_channel_preview_title_appearance, R.style.SendbirdSubtitle1OnLight01 ) val memberCountAppearance = a.getResourceId( - R.styleable.ChannelPreview_sb_channel_preview_member_count_appearance, - R.style.SendbirdCaption1OnLight02 + R.styleable.ChannelPreview_sb_channel_preview_member_count_appearance, R.style.SendbirdCaption1OnLight02 ) val updatedAtAppearance = a.getResourceId( - R.styleable.ChannelPreview_sb_channel_preview_updated_at_appearance, - R.style.SendbirdCaption2OnLight02 + R.styleable.ChannelPreview_sb_channel_preview_updated_at_appearance, R.style.SendbirdCaption2OnLight02 ) val unReadCountAppearance = a.getResourceId( - R.styleable.ChannelPreview_sb_channel_preview_unread_count_appearance, - R.style.SendbirdCaption1OnDark01 + R.styleable.ChannelPreview_sb_channel_preview_unread_count_appearance, R.style.SendbirdCaption1OnDark01 ) val unReadMentionCountAppearance = a.getResourceId( R.styleable.ChannelPreview_sb_channel_preview_unread_mention_count_appearance, R.style.SendbirdH2Primary300 ) val lastMessageAppearance = a.getResourceId( - R.styleable.ChannelPreview_sb_channel_preview_last_message_appearance, - R.style.SendbirdBody3OnLight03 + R.styleable.ChannelPreview_sb_channel_preview_last_message_appearance, R.style.SendbirdBody3OnLight03 ) layout.findViewById(R.id.root).setBackgroundResource(background) tvTitle.setAppearance(context, titleAppearance) @@ -107,12 +100,13 @@ internal class ChannelPreview @JvmOverloads constructor( val pushEnabledTint = SendbirdUIKit.getDefaultThemeMode().monoTintResId ivPushEnabled.setImageDrawable( DrawableUtils.setTintList( - context, - R.drawable.icon_notifications_off_filled, - pushEnabledTint + context, R.drawable.icon_notifications_off_filled, pushEnabledTint ) ) - tvTitle.text = ChannelUtils.makeTitleText(context, channel) + tvTitle.text = + if (channel.isChatNotification) channel.name.ifEmpty { context.getString(R.string.sb_text_channel_list_title_unknown) } else ChannelUtils.makeTitleText( + context, channel + ) tvUnreadCount.text = if (unreadMessageCount > 99) context.getString(R.string.sb_text_channel_list_unread_count_max) else unreadMessageCount.toString() tvUnreadCount.visibility = if (unreadMessageCount > 0) VISIBLE else GONE @@ -132,8 +126,7 @@ internal class ChannelPreview @JvmOverloads constructor( tvMemberCount.visibility = if (memberCount > 2) VISIBLE else GONE tvMemberCount.text = ChannelUtils.makeMemberCountText(channel.memberCount) tvUpdatedAt.text = DateUtils.formatDateTime( - context, - lastMessage?.createdAt ?: channel.createdAt + context, lastMessage?.createdAt ?: channel.createdAt ) setLastMessage(tvLastMessage, channel, useTypingIndicator) ivLastMessageStatus.visibility = if (useMessageReceiptStatus) VISIBLE else GONE @@ -199,7 +192,9 @@ internal class ChannelPreview @JvmOverloads constructor( channel.lastMessage?.let { when (it) { + is AdminMessage, is UserMessage -> { + if (it is AdminMessage && !channel.isChatNotification) return@let textView.maxLines = 2 textView.ellipsize = TextUtils.TruncateAt.END message = it.message diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/ui/components/HeaderView.kt b/uikit/src/main/java/com/sendbird/uikit/internal/ui/components/HeaderView.kt index 401c1345..f83540e3 100644 --- a/uikit/src/main/java/com/sendbird/uikit/internal/ui/components/HeaderView.kt +++ b/uikit/src/main/java/com/sendbird/uikit/internal/ui/components/HeaderView.kt @@ -8,6 +8,7 @@ import android.view.LayoutInflater import android.widget.FrameLayout import android.widget.ImageButton import android.widget.TextView +import androidx.annotation.ColorInt import androidx.annotation.Dimension import androidx.annotation.DrawableRes import com.sendbird.uikit.R @@ -69,6 +70,14 @@ internal class HeaderView @JvmOverloads constructor( binding.rightButton.visibility = if (useRightButton) VISIBLE else GONE } + fun setDividerColor(@ColorInt color: Int) { + binding.elevationView.setBackgroundColor(color) + } + + override fun setBackgroundColor(@ColorInt color: Int) { + binding.getRoot().setBackgroundColor(color) + } + val descriptionTextView: TextView get() = binding.description val profileView: ChannelCoverView diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/ui/messages/ChatNotificationView.kt b/uikit/src/main/java/com/sendbird/uikit/internal/ui/messages/ChatNotificationView.kt new file mode 100644 index 00000000..223107a1 --- /dev/null +++ b/uikit/src/main/java/com/sendbird/uikit/internal/ui/messages/ChatNotificationView.kt @@ -0,0 +1,149 @@ +package com.sendbird.uikit.internal.ui.messages + +import android.content.Context +import android.util.AttributeSet +import android.util.TypedValue +import android.view.LayoutInflater +import android.view.View +import com.sendbird.android.channel.BaseChannel +import com.sendbird.android.message.BaseMessage +import com.sendbird.uikit.R +import com.sendbird.uikit.databinding.SbViewChatNotificationComponentBinding +import com.sendbird.uikit.interfaces.OnNotificationTemplateActionHandler +import com.sendbird.uikit.internal.extensions.addRipple +import com.sendbird.uikit.internal.extensions.loadCircle +import com.sendbird.uikit.internal.extensions.setAppearance +import com.sendbird.uikit.internal.extensions.toStringMap +import com.sendbird.uikit.internal.model.notifications.NotificationConfig +import com.sendbird.uikit.internal.model.notifications.NotificationThemeMode +import com.sendbird.uikit.internal.model.template_messages.KeySet +import com.sendbird.uikit.internal.model.template_messages.Params +import com.sendbird.uikit.internal.model.template_messages.TemplateViewGenerator +import com.sendbird.uikit.internal.singleton.MessageTemplateParser +import com.sendbird.uikit.internal.singleton.NotificationChannelManager +import com.sendbird.uikit.log.Logger +import com.sendbird.uikit.utils.DateUtils +import org.json.JSONObject + +internal class ChatNotificationView @JvmOverloads internal constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = R.attr.sb_widget_chat_notification +) : BaseMessageView(context, attrs, defStyle) { + override val binding: SbViewChatNotificationComponentBinding + override val layout: View + get() = binding.root + + var onNotificationTemplateActionHandler: OnNotificationTemplateActionHandler? = null + + init { + val a = + context.theme.obtainStyledAttributes(attrs, R.styleable.MessageView_ChatNotification, defStyle, 0) + try { + binding = SbViewChatNotificationComponentBinding.inflate( + LayoutInflater.from(context), + this, + true + ) + val messageBackground = a.getResourceId( + R.styleable.MessageView_ChatNotification_sb_chat_notification_background, + R.color.background_100 + ) + val leftCaptionAppearance = a.getResourceId( + R.styleable.MessageView_ChatNotification_sb_chat_notification_category_text_appearance, + R.style.SendbirdCaption1OnLight02 + ) + val rightCaptionAppearance = a.getResourceId( + R.styleable.MessageView_ChatNotification_sb_chat_notification_sent_at_text_appearance, + R.style.SendbirdCaption4OnLight03 + ) + val bubbleRadius = a.getDimensionPixelSize( + R.styleable.MessageView_ChatNotification_sb_chat_notification_radius, + context.resources.getDimensionPixelSize(R.dimen.sb_size_8) + ) + + binding.contentPanel.setBackgroundResource(messageBackground) + binding.tvCategory.setAppearance(context, leftCaptionAppearance) + binding.tvSentAt.setAppearance(context, rightCaptionAppearance) + binding.contentPanel.radius = bubbleRadius.toFloat() + } finally { + a.recycle() + } + } + + fun drawMessage(channel: BaseChannel, message: BaseMessage, config: NotificationConfig? = null) { + binding.tvCategory.text = message.customType + binding.tvSentAt.text = DateUtils.formatDateTime(context, message.createdAt) + binding.ivProfileView.loadCircle(channel.coverUrl) + + // apply config + config?.let { + it.theme.notificationTheme.apply { + val themeMode = config.themeMode + category.apply { + binding.tvCategory.setTextColor(textColor.getColor(themeMode)) + binding.tvCategory.setTextSize(TypedValue.COMPLEX_UNIT_SP, textSize.toFloat()) + } + sentAt.apply { + binding.tvSentAt.setTextColor(textColor.getColor(themeMode)) + binding.tvSentAt.setTextSize(TypedValue.COMPLEX_UNIT_SP, textSize.toFloat()) + } + + val theme = this + binding.contentPanel.apply { + isClickable = true + isFocusable = true + setRadiusIntSize(theme.radius) + setBackgroundColor(theme.backgroundColor.getColor(themeMode)) + addRipple(theme.pressedColor.getColor(themeMode)) + } + } + } + + binding.contentPanel.removeAllViews() + binding.contentPanel.addView( + makeTemplateView( + message, + config?.themeMode ?: NotificationThemeMode.Default, + onNotificationTemplateActionHandler + ) + ) + } + + @Throws(Throwable::class) + private fun makeTemplateView( + message: BaseMessage, + themeMode: NotificationThemeMode, + onNotificationTemplateActionHandler: OnNotificationTemplateActionHandler? = null + ): View { + return try { + val subData: String = message.extendedMessage[KeySet.sub_data] + ?: throw RuntimeException("this message must have template key.") + val json = JSONObject(subData) + val templateKey = json.getString(KeySet.template_key) + var templateVariables: Map = mapOf() + if (json.has(KeySet.template_variables)) { + templateVariables = json.getJSONObject(KeySet.template_variables).toStringMap() + } + val template = NotificationChannelManager.getTemplate(templateKey, templateVariables, themeMode) + template?.let { jsonTemplate -> + val viewParams: Params = MessageTemplateParser.parse(jsonTemplate) + TemplateViewGenerator.inflateViews(context, viewParams) { view, params -> + params.action?.register(view, onNotificationTemplateActionHandler, message) + } + } ?: throw RuntimeException("binding color variables or data variables are failed") + } catch (e: Throwable) { + Logger.w("${e.printStackTrace()}") + MessageTemplateParser.createDefaultViewParam( + message, + context.getString(R.string.sb_text_notification_fallback_title), + context.getString(R.string.sb_text_notification_fallback_description), + themeMode + ).run { + TemplateViewGenerator.inflateViews(context, this) { view, params -> + params.action?.register(view, onNotificationTemplateActionHandler, message) + } + } + } + } +} diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/ui/messages/FeedNotificationView.kt b/uikit/src/main/java/com/sendbird/uikit/internal/ui/messages/FeedNotificationView.kt new file mode 100644 index 00000000..561bccfe --- /dev/null +++ b/uikit/src/main/java/com/sendbird/uikit/internal/ui/messages/FeedNotificationView.kt @@ -0,0 +1,164 @@ +package com.sendbird.uikit.internal.ui.messages + +import android.content.Context +import android.graphics.drawable.ShapeDrawable +import android.graphics.drawable.shapes.OvalShape +import android.util.AttributeSet +import android.util.TypedValue +import android.view.LayoutInflater +import android.view.View +import com.sendbird.android.message.BaseMessage +import com.sendbird.uikit.R +import com.sendbird.uikit.databinding.SbViewFeedNotificationComponentBinding +import com.sendbird.uikit.interfaces.OnNotificationTemplateActionHandler +import com.sendbird.uikit.internal.extensions.addRipple +import com.sendbird.uikit.internal.extensions.setAppearance +import com.sendbird.uikit.internal.extensions.toStringMap +import com.sendbird.uikit.internal.model.notifications.NotificationConfig +import com.sendbird.uikit.internal.model.notifications.NotificationThemeMode +import com.sendbird.uikit.internal.model.template_messages.KeySet +import com.sendbird.uikit.internal.model.template_messages.Params +import com.sendbird.uikit.internal.model.template_messages.TemplateViewGenerator +import com.sendbird.uikit.internal.singleton.MessageTemplateParser +import com.sendbird.uikit.internal.singleton.NotificationChannelManager +import com.sendbird.uikit.log.Logger +import com.sendbird.uikit.utils.DateUtils +import com.sendbird.uikit.utils.DrawableUtils +import org.json.JSONObject + + +internal class FeedNotificationView @JvmOverloads internal constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = R.attr.sb_widget_feed_notification +) : BaseMessageView(context, attrs, defStyle) { + override val binding: SbViewFeedNotificationComponentBinding + override val layout: View + get() = binding.root + + var onNotificationTemplateActionHandler: OnNotificationTemplateActionHandler? = null + + init { + val a = + context.theme.obtainStyledAttributes(attrs, R.styleable.MessageView_FeedNotification, defStyle, 0) + try { + binding = SbViewFeedNotificationComponentBinding.inflate( + LayoutInflater.from(context), + this, + true + ) + val messageBackground = a.getResourceId( + R.styleable.MessageView_FeedNotification_sb_feed_notification_background, + R.color.background_100 + ) + val leftCaptionAppearance = a.getResourceId( + R.styleable.MessageView_FeedNotification_sb_feed_notification_category_text_appearance, + R.style.SendbirdCaption1OnLight02 + ) + val rightCaptionAppearance = a.getResourceId( + R.styleable.MessageView_FeedNotification_sb_feed_notification_sent_at_text_appearance, + R.style.SendbirdCaption4OnLight03 + ) + val bubbleRadius = a.getDimensionPixelSize( + R.styleable.MessageView_FeedNotification_sb_feed_notification_radius, + context.resources.getDimensionPixelSize(R.dimen.sb_size_8) + ) + val unreadIndicatorColor = a.getResourceId( + R.styleable.MessageView_FeedNotification_sb_feed_notification_unread_indicator_color, + R.color.secondary_300 + ) + + binding.contentPanel.setBackgroundResource(messageBackground) + binding.tvCategory.setAppearance(context, leftCaptionAppearance) + binding.tvSentAt.setAppearance(context, rightCaptionAppearance) + binding.contentPanel.radius = bubbleRadius.toFloat() + binding.ivUnreadIndicator.background = DrawableUtils.createOvalIcon(context, unreadIndicatorColor) + } finally { + a.recycle() + } + } + + @JvmOverloads + fun drawMessage(message: BaseMessage, lastSeen: Long, config: NotificationConfig? = null) { + binding.tvCategory.text = message.customType + binding.tvSentAt.text = DateUtils.formatDateTime(context, message.createdAt) + binding.ivUnreadIndicator.visibility = + if (message.createdAt > lastSeen) View.VISIBLE else View.GONE + + // apply config + config?.let { + it.theme.notificationTheme.apply { + val themeMode = config.themeMode + category.apply { + binding.tvCategory.setTextColor(textColor.getColor(themeMode)) + binding.tvCategory.setTextSize(TypedValue.COMPLEX_UNIT_SP, textSize.toFloat()) + } + sentAt.apply { + binding.tvSentAt.setTextColor(textColor.getColor(themeMode)) + binding.tvSentAt.setTextSize(TypedValue.COMPLEX_UNIT_SP, textSize.toFloat()) + } + unreadIndicatorColor.getColor(themeMode).apply { + val color = this + binding.ivUnreadIndicator.background = ShapeDrawable(OvalShape()).apply { + paint.color = color + } + } + + val theme = this + binding.contentPanel.apply { + isClickable = true + isFocusable = true + setRadiusIntSize(theme.radius) + setBackgroundColor(theme.backgroundColor.getColor(themeMode)) + addRipple(theme.pressedColor.getColor(themeMode)) + } + } + } + + binding.contentPanel.removeAllViews() + binding.contentPanel.addView( + makeTemplateView( + message, + config?.themeMode ?: NotificationThemeMode.Default, + onNotificationTemplateActionHandler + ) + ) + } + + @Throws(Throwable::class) + private fun makeTemplateView( + message: BaseMessage, + themeMode: NotificationThemeMode, + onNotificationTemplateActionHandler: OnNotificationTemplateActionHandler? = null + ): View { + return try { + val subData: String = message.extendedMessage[KeySet.sub_data] + ?: throw RuntimeException("this message must have template key.") + val json = JSONObject(subData) + val templateKey = json.getString(KeySet.template_key) + var templateVariables: Map = mapOf() + if (json.has(KeySet.template_variables)) { + templateVariables = json.getJSONObject(KeySet.template_variables).toStringMap() + } + val template = NotificationChannelManager.getTemplate(templateKey, templateVariables, themeMode) + template?.let { jsonTemplate -> + val viewParams: Params = MessageTemplateParser.parse(jsonTemplate) + TemplateViewGenerator.inflateViews(context, viewParams) { view, params -> + params.action?.register(view, onNotificationTemplateActionHandler, message) + } + } ?: throw RuntimeException("binding color variables or data variables are failed") + } catch (e: Throwable) { + Logger.w("${e.printStackTrace()}") + MessageTemplateParser.createDefaultViewParam( + message, + context.getString(R.string.sb_text_notification_fallback_title), + context.getString(R.string.sb_text_notification_fallback_description), + themeMode + ).run { + TemplateViewGenerator.inflateViews(context, this) { view, params -> + params.action?.register(view, onNotificationTemplateActionHandler, message) + } + } + } + } +} diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/ui/messages/TimelineMessageView.kt b/uikit/src/main/java/com/sendbird/uikit/internal/ui/messages/TimelineMessageView.kt index 6902f895..685290d1 100644 --- a/uikit/src/main/java/com/sendbird/uikit/internal/ui/messages/TimelineMessageView.kt +++ b/uikit/src/main/java/com/sendbird/uikit/internal/ui/messages/TimelineMessageView.kt @@ -7,8 +7,11 @@ import android.view.View import com.sendbird.android.message.BaseMessage import com.sendbird.uikit.R import com.sendbird.uikit.databinding.SbViewTimeLineMessageComponentBinding +import com.sendbird.uikit.internal.extensions.intToDp import com.sendbird.uikit.internal.extensions.setAppearance +import com.sendbird.uikit.internal.model.notifications.NotificationConfig import com.sendbird.uikit.utils.DateUtils +import com.sendbird.uikit.utils.DrawableUtils internal class TimelineMessageView @JvmOverloads internal constructor( context: Context, @@ -45,4 +48,20 @@ internal class TimelineMessageView @JvmOverloads internal constructor( fun drawTimeline(message: BaseMessage) { binding.tvTimeline.text = DateUtils.formatTimelineMessage(message.createdAt) } + + fun drawTimeline(message: BaseMessage, uiConfig: NotificationConfig?) { + binding.tvTimeline.run { + text = DateUtils.formatTimelineMessage(message.createdAt) + uiConfig?.let { + val themeMode = it.themeMode + it.theme.listTheme.timeline.apply { + DrawableUtils.createRoundedShapeDrawable( + backgroundColor.getColor(themeMode), + resources.intToDp(10).toFloat() + ).apply { background = this } + setTextColor(textColor.getColor(themeMode)) + } + } + } + } } diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/ui/messages/VoiceMessageView.kt b/uikit/src/main/java/com/sendbird/uikit/internal/ui/messages/VoiceMessageView.kt index f62ca7bf..26c4a2db 100644 --- a/uikit/src/main/java/com/sendbird/uikit/internal/ui/messages/VoiceMessageView.kt +++ b/uikit/src/main/java/com/sendbird/uikit/internal/ui/messages/VoiceMessageView.kt @@ -8,7 +8,6 @@ import android.view.LayoutInflater import android.view.View import android.widget.FrameLayout import com.sendbird.android.message.FileMessage -import com.sendbird.android.message.SendingStatus import com.sendbird.uikit.R import com.sendbird.uikit.databinding.SbViewVoiceMessageBinding import com.sendbird.uikit.internal.extensions.setAppearance diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/ui/notifications/ChatNotificationChannelModule.kt b/uikit/src/main/java/com/sendbird/uikit/internal/ui/notifications/ChatNotificationChannelModule.kt new file mode 100644 index 00000000..50debe2b --- /dev/null +++ b/uikit/src/main/java/com/sendbird/uikit/internal/ui/notifications/ChatNotificationChannelModule.kt @@ -0,0 +1,178 @@ +package com.sendbird.uikit.internal.ui.notifications + +import android.content.Context +import android.os.Bundle +import android.util.TypedValue +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.LinearLayout +import androidx.annotation.StyleRes +import androidx.appcompat.view.ContextThemeWrapper +import com.sendbird.uikit.R +import com.sendbird.uikit.SendbirdUIKit +import com.sendbird.uikit.SendbirdUIKit.ThemeMode +import com.sendbird.uikit.interfaces.LoadingDialogHandler +import com.sendbird.uikit.internal.model.notifications.NotificationConfig +import com.sendbird.uikit.modules.BaseModule +import com.sendbird.uikit.modules.components.StatusComponent + +/** + * A module for notification channel. + * All composed components are created when the module is created. After than those components can replace. + * + * @since 3.5.0 + */ +@JvmSuppressWildcards +internal class ChatNotificationChannelModule @JvmOverloads constructor( + context: Context, + uiConfig: NotificationConfig?, + private val params: Params = Params(context) +) : BaseModule() { + + /** + * Returns the notification channel header component. + * + * @return The channel header component of this module + * @since 3.5.0 + */ + var headerComponent: ChatNotificationHeaderComponent + private set + + /** + * Sets a custom notification list component. + * + * @param component The notification list component to be used in this module + * @since 3.5.0 + */ + var notificationListComponent: ChatNotificationListComponent + private set + + /** + * Returns the status component. + * + * @return The status component of this module + * @since 3.5.0 + */ + var statusComponent: StatusComponent + private set + + /** + * Returns the handler for loading dialog. + * + * @return Loading dialog handler to be used in this module + * @since 3.5.0 + */ + var loadingDialogHandler: LoadingDialogHandler? = null + private set + + /** + * Constructor + * + * @param context The `Context` this module is currently associated with + * @since 3.5.0 + */ + init { + headerComponent = ChatNotificationHeaderComponent(uiConfig).apply { + params.setUseRightButton(false) + } + notificationListComponent = ChatNotificationListComponent(uiConfig = uiConfig) + statusComponent = StatusComponent() + } + + override fun onCreateView(context: Context, inflater: LayoutInflater, args: Bundle?): View { + args?.let { params.applyArgs(context, it) } + val moduleContext: Context = ContextThemeWrapper(context, params.theme) + val parent = LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + layoutParams = + LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) + } + + val values = TypedValue() + if (params.shouldUseHeader()) { + moduleContext.theme.resolveAttribute(R.attr.sb_component_header, values, true) + val headerThemeContext: Context = ContextThemeWrapper(moduleContext, values.resourceId) + val headerInflater = inflater.cloneInContext(headerThemeContext) + val header = headerComponent.onCreateView(headerThemeContext, headerInflater, parent, args) + parent.addView(header) + } + + val innerContainer = FrameLayout(context).apply { + layoutParams = + FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) + } + parent.addView(innerContainer) + + moduleContext.theme.resolveAttribute(R.attr.sb_component_list, values, true) + val listThemeContext: Context = ContextThemeWrapper(moduleContext, values.resourceId) + val listInflater = inflater.cloneInContext(listThemeContext) + val channelListLayout = + notificationListComponent.onCreateView(listThemeContext, listInflater, innerContainer, args) + innerContainer.addView(channelListLayout) + moduleContext.theme.resolveAttribute(R.attr.sb_component_status, values, true) + + val statusThemeContext: Context = ContextThemeWrapper(moduleContext, values.resourceId) + val statusInflater = inflater.cloneInContext(statusThemeContext) + val statusLayout = statusComponent.onCreateView(statusThemeContext, statusInflater, innerContainer, args) + innerContainer.addView(statusLayout) + return parent + } + + /** + * Sets the handler for the loading dialog. + * + * @param loadingDialogHandler Loading dialog handler to be used in this module + * @since 3.5.0 + */ + fun setOnLoadingDialogHandler(loadingDialogHandler: LoadingDialogHandler?) { + this.loadingDialogHandler = loadingDialogHandler + } + + /** + * It will be called when the loading dialog needs displaying. + * + * @return True if the callback has consumed the event, false otherwise. + * @since 3.5.0 + */ + fun shouldShowLoadingDialog(): Boolean { + return loadingDialogHandler?.shouldShowLoadingDialog() ?: false + // Do nothing on the channel. + } + + /** + * It will be called when the loading dialog needs dismissing. + * + * @since 3.5.0 + */ + fun shouldDismissLoadingDialog() { + loadingDialogHandler?.shouldDismissLoadingDialog() + } + + class Params : BaseModule.Params { + @JvmOverloads + constructor(context: Context, themeMode: ThemeMode = SendbirdUIKit.getDefaultThemeMode()) : super( + context, + themeMode, + R.attr.sb_module_chat_notification_channel + ) + + /** + * Constructor + * + * @param context The `Context` this module is currently associated with + * @param themeResId The theme resource ID to be applied to this module + * @since 3.5.0 + */ + constructor(context: Context, @StyleRes themeResId: Int) : super( + context, + themeResId, + R.attr.sb_module_chat_notification_channel + ) + + fun applyArgs(context: Context, args: Bundle): Params { + return super.apply(context, args) as Params + } + } +} diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/ui/notifications/ChatNotificationHeaderComponent.kt b/uikit/src/main/java/com/sendbird/uikit/internal/ui/notifications/ChatNotificationHeaderComponent.kt new file mode 100644 index 00000000..6ef6d9ff --- /dev/null +++ b/uikit/src/main/java/com/sendbird/uikit/internal/ui/notifications/ChatNotificationHeaderComponent.kt @@ -0,0 +1,90 @@ +package com.sendbird.uikit.internal.ui.notifications + +import android.content.Context +import android.content.res.ColorStateList +import android.os.Bundle +import android.util.TypedValue +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.sendbird.android.channel.GroupChannel +import com.sendbird.uikit.internal.model.notifications.NotificationConfig +import com.sendbird.uikit.internal.ui.components.HeaderView +import com.sendbird.uikit.modules.components.HeaderComponent + +/** + * This class creates and performs a view corresponding the channel header area in Sendbird UIKit. + * + * @since 3.5.0 + */ +@JvmSuppressWildcards +internal class ChatNotificationHeaderComponent( + private val uiConfig: NotificationConfig? = null +) : HeaderComponent(Params()) { + /** + * Returns a collection of parameters applied to this component. + * + * @return `Params` applied to this component + * @since 3.5.0 + */ + override fun getParams(): Params { + return super.getParams() as Params + } + + /** + * Called after the component was created to make views. + * + * **If this function is used override, [.getRootView] must also be override.** + * + * @param context The `Context` this component is currently associated with + * @param inflater The LayoutInflater object that can be used to inflate any views in the component + * @param parent The ViewGroup into which the new View will be added + * @param args The arguments supplied when the component was instantiated, if any + * @return Return the View for the UI. + * @since 3.5.0 + */ + override fun onCreateView(context: Context, inflater: LayoutInflater, parent: ViewGroup, args: Bundle?): View { + val layout = super.onCreateView(context, inflater, parent, args) + if (layout is HeaderView) { + layout.descriptionTextView.visibility = View.GONE + layout.profileView.visibility = View.VISIBLE + + uiConfig?.let { + val themeMode = it.themeMode + it.theme.headerTheme.apply { + layout.setBackgroundColor(backgroundColor.getColor(themeMode)) + layout.setDividerColor(lineColor.getColor(themeMode)) + layout.titleTextView.setTextSize(TypedValue.COMPLEX_UNIT_SP, textSize.toFloat()) + layout.titleTextView.setTextColor(textColor.getColor(themeMode)) + layout.leftButton.imageTintList = ColorStateList.valueOf(buttonIconTintColor.getColor(themeMode)) + } + } + } + return layout + } + + /** + * Notifies this component that the channel data has changed. + * + * @param channel The latest group channel + * @since 3.5.0 + */ + fun notifyChannelChanged(channel: GroupChannel) { + val rootView = rootView as? HeaderView ?: return + rootView.profileView.loadImage(channel.coverUrl) + if (params.title == null) { + rootView.titleTextView.text = channel.name + } + } + + /** + * A collection of parameters, which can be applied to a default View. The values of params are not dynamically applied at runtime. + * Params cannot be created directly, and it is automatically created together when components are created. + * + * **Since the onCreateView configuring View uses the values of the set Params, we recommend that you set up for Params before the onCreateView is called.** + * + * @see .getParams + * @since 3.5.0 + */ + class Params : HeaderComponent.Params() +} diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/ui/notifications/ChatNotificationListAdapter.kt b/uikit/src/main/java/com/sendbird/uikit/internal/ui/notifications/ChatNotificationListAdapter.kt new file mode 100644 index 00000000..31ce982c --- /dev/null +++ b/uikit/src/main/java/com/sendbird/uikit/internal/ui/notifications/ChatNotificationListAdapter.kt @@ -0,0 +1,171 @@ +package com.sendbird.uikit.internal.ui.notifications + +import android.content.Context +import android.util.TypedValue +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.appcompat.view.ContextThemeWrapper +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import com.sendbird.android.channel.GroupChannel +import com.sendbird.android.message.BaseMessage +import com.sendbird.uikit.R +import com.sendbird.uikit.SendbirdUIKit +import com.sendbird.uikit.activities.viewholder.MessageType +import com.sendbird.uikit.activities.viewholder.MessageViewHolderFactory +import com.sendbird.uikit.databinding.SbViewChatNotificationBinding +import com.sendbird.uikit.databinding.SbViewTimeLineMessageBinding +import com.sendbird.uikit.interfaces.OnMessageListUpdateHandler +import com.sendbird.uikit.interfaces.OnNotificationTemplateActionHandler +import com.sendbird.uikit.internal.model.NotificationDiffCallback +import com.sendbird.uikit.internal.model.notifications.NotificationConfig +import com.sendbird.uikit.internal.ui.viewholders.ChatNotificationViewHolder +import com.sendbird.uikit.internal.ui.viewholders.NotificationTimelineViewHolder +import com.sendbird.uikit.internal.ui.viewholders.NotificationViewHolder +import com.sendbird.uikit.model.TimelineMessage +import java.util.Collections +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors + +internal class ChatNotificationListAdapter( + private var channel: GroupChannel, + private val notificationConfig: NotificationConfig? +) : RecyclerView.Adapter() { + private var messageList: List = listOf() + + // the worker must be a single thread. + private val differWorker by lazy { Executors.newSingleThreadExecutor() } + var onMessageTemplateActionHandler: OnNotificationTemplateActionHandler? = null + set(value) { + field = value + notificationConfig?.onMessageTemplateActionHandler = onMessageTemplateActionHandler + } + + /** + * Called when RecyclerView needs a new [NotificationViewHolder] of the given type to represent + * an item. + * + * @param parent The ViewGroup into which the new View will be added after it is bound to + * an adapter position. + * @param viewType The view type of the new View. + * @return A new [NotificationViewHolder] that holds a View of the given view type. + * @see .getItemViewType + * @see .onBindViewHolder + * @since 3.5.0 + */ + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NotificationViewHolder { + val values = TypedValue() + parent.context.theme.resolveAttribute(R.attr.sb_component_list, values, true) + val contextWrapper: Context = ContextThemeWrapper(parent.context, values.resourceId) + val inflater = LayoutInflater.from(contextWrapper) + if (MessageType.from(viewType) == MessageType.VIEW_TYPE_TIME_LINE) { + return NotificationTimelineViewHolder(SbViewTimeLineMessageBinding.inflate(inflater, parent, false)) + } + return ChatNotificationViewHolder(SbViewChatNotificationBinding.inflate(inflater, parent, false)) + } + + /** + * Called by RecyclerView to display the data at the specified position. This method should + * update the contents of the [NotificationViewHolder.itemView] to reflect the item at the given + * position. + * + * @param holder The [NotificationViewHolder] which should be updated to represent + * the contents of the item at the given position in the data set. + * @param position The position of the item within the adapter's data set. + * @since 3.5.0 + */ + override fun onBindViewHolder(holder: NotificationViewHolder, position: Int) { + val message = getItem(position) + holder.bind(channel, message, notificationConfig) + } + + /** + * Return the view type of the [NotificationViewHolder]. + * Notification channel always returns [MessageType.VIEW_TYPE_CHAT_NOTIFICATION] + * + * @param position position to query + * @return integer value identifying the type of the view needed to represent the item at `position`. + * @see MessageViewHolderFactory.getViewType + * @since 3.5.0 + */ + override fun getItemViewType(position: Int): Int { + return if (getItem(position) is TimelineMessage) { + MessageType.VIEW_TYPE_TIME_LINE.value + } else { + MessageType.VIEW_TYPE_CHAT_NOTIFICATION.value + } + } + + /** + * Return ID for the message at `position`. + * + * @param position Adapter position to query + * @return the stable ID of the item at position + * @since 3.5.0 + */ + override fun getItemId(position: Int): Long { + return getItem(position).messageId + } + + /** + * Sets the [<] to be displayed. + * + * @param messageList list to be displayed + * @since 3.5.0 + */ + fun setItems(channel: GroupChannel, messageList: List, callback: OnMessageListUpdateHandler?) { + val copiedChannel = GroupChannel.clone(channel) + val copiedMessage = Collections.unmodifiableList(messageList) + differWorker.submit { + val lock = CountDownLatch(1) + val diffCallback = NotificationDiffCallback( + this@ChatNotificationListAdapter.messageList, + messageList + ) + val diffResult = DiffUtil.calculateDiff(diffCallback) + SendbirdUIKit.runOnUIThread { + try { + this@ChatNotificationListAdapter.messageList = copiedMessage + this@ChatNotificationListAdapter.channel = copiedChannel + diffResult.dispatchUpdatesTo(this@ChatNotificationListAdapter) + callback?.onListUpdated(messageList) + } finally { + lock.countDown() + } + } + lock.await() + true + } + } + + /** + * Returns the total number of items in the data set held by the adapter. + * + * @return The total number of items in this adapter. + * @since 3.5.0 + */ + override fun getItemCount(): Int { + return messageList.size + } + + /** + * Returns the [BaseMessage] in the data set held by the adapter. + * + * @param position The position of the item within the adapter's data set. + * @return The [BaseMessage] to retrieve the position of in this adapter. + * @since 3.5.0 + */ + fun getItem(position: Int): BaseMessage { + return messageList[position] + } + + /** + * Returns the [<] in the data set held by the adapter. + * + * @return The [<] in this adapter. + * @since 3.5.0 + */ + fun getItems(): List { + return Collections.unmodifiableList(messageList) + } +} diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/ui/notifications/ChatNotificationListComponent.kt b/uikit/src/main/java/com/sendbird/uikit/internal/ui/notifications/ChatNotificationListComponent.kt new file mode 100644 index 00000000..97fb62ad --- /dev/null +++ b/uikit/src/main/java/com/sendbird/uikit/internal/ui/notifications/ChatNotificationListComponent.kt @@ -0,0 +1,109 @@ +package com.sendbird.uikit.internal.ui.notifications + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.sendbird.android.channel.GroupChannel +import com.sendbird.android.message.BaseMessage +import com.sendbird.uikit.interfaces.OnMessageListUpdateHandler +import com.sendbird.uikit.interfaces.OnNotificationTemplateActionHandler +import com.sendbird.uikit.internal.model.notifications.NotificationConfig +import com.sendbird.uikit.internal.ui.widgets.InnerLinearLayoutManager +import com.sendbird.uikit.model.Action + +/** + * This class creates and performs a view corresponding the notification message list area in Sendbird UIKit. + * + * @since 3.5.0 + */ +@JvmSuppressWildcards +internal open class ChatNotificationListComponent @JvmOverloads constructor( + params: Params = Params(), + uiConfig: NotificationConfig? = null +) : NotificationListComponent(params, uiConfig) { + /** + * Returns the chat notification list adapter. + * + * @return The adapter applied to this list component + * @since 3.5.0 + */ + private var adapter: ChatNotificationListAdapter? = null + private set(value) { + field = value + notificationListView?.recyclerView?.let { + if (value?.onMessageTemplateActionHandler == null) { + value?.onMessageTemplateActionHandler = + OnNotificationTemplateActionHandler { view: View, action: Action, message: BaseMessage -> + onMessageTemplateActionClicked( + view, + action, + message + ) + } + } + it.adapter = value + } + } + + override fun onCreateView(context: Context, inflater: LayoutInflater, parent: ViewGroup, args: Bundle?): View { + val layout = super.onCreateView(context, inflater, parent, args) + val layoutManager = InnerLinearLayoutManager(context).apply { reverseLayout = true } + notificationListView?.recyclerView?.layoutManager = layoutManager + return layout + } + + /** + * Handles a new channel when data has changed. + * + * @param channel The latest group channel + * @since 3.5.0 + */ + fun notifyChannelChanged(channel: GroupChannel) { + if (adapter == null) { + adapter = ChatNotificationListAdapter(channel, uiConfig) + } + } + + /** + * Handles the data needed to draw the message list has changed. + * + * @param notificationList The list of messages to be drawn + * @param channel The latest group channel + * @param callback Callback when the message list is updated + * @since 3.5.0 + */ + fun notifyDataSetChanged( + notificationList: List, + channel: GroupChannel, + callback: OnMessageListUpdateHandler? + ) { + notificationListView?.let { + adapter?.setItems(channel, notificationList, callback) + } + } + + /** + * A collection of parameters, which can be applied to a default View. The values of params are not dynamically applied at runtime. + * Params cannot be created directly, and it is automatically created together when components are created. + * + * **Since the onCreateView configuring View uses the values of the set Params, we recommend that you set up for Params before the onCreateView is called.** + * + * @see .getParams + * @since 3.5.0 + */ + open class Params : NotificationListComponent.Params() { + /** + * Apply data that matches keys mapped to Params' properties. + * + * @param context The `Context` this component is currently associated with + * @param args The sets of arguments to apply at Params. + * @return This Params object that applied with given data. + * @since 3.5.0 + */ + override fun apply(context: Context, args: Bundle): Params { + return this + } + } +} diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/ui/notifications/FeedNotificationChannelModule.kt b/uikit/src/main/java/com/sendbird/uikit/internal/ui/notifications/FeedNotificationChannelModule.kt new file mode 100644 index 00000000..2641c14e --- /dev/null +++ b/uikit/src/main/java/com/sendbird/uikit/internal/ui/notifications/FeedNotificationChannelModule.kt @@ -0,0 +1,173 @@ +package com.sendbird.uikit.internal.ui.notifications + +import android.content.Context +import android.os.Bundle +import android.util.TypedValue +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.LinearLayout +import androidx.annotation.StyleRes +import androidx.appcompat.view.ContextThemeWrapper +import com.sendbird.uikit.R +import com.sendbird.uikit.SendbirdUIKit +import com.sendbird.uikit.SendbirdUIKit.ThemeMode +import com.sendbird.uikit.interfaces.LoadingDialogHandler +import com.sendbird.uikit.internal.model.notifications.NotificationConfig +import com.sendbird.uikit.modules.BaseModule +import com.sendbird.uikit.modules.components.StatusComponent + +/** + * A module for notification channel. + * All composed components are created when the module is created. After than those components can replace. + * + * @since 3.5.0 + */ +@JvmSuppressWildcards +internal class FeedNotificationChannelModule @JvmOverloads constructor( + context: Context, + uiConfig: NotificationConfig?, + private val params: Params = Params(context) +) : BaseModule() { + + /** + * Returns the notification channel header component. + * + * @return The channel header component of this module + * @since 3.5.0 + */ + var headerComponent: FeedNotificationHeaderComponent + private set + + /** + * Sets a custom notification list component. + * + * @param component The notification list component to be used in this module + * @since 3.5.0 + */ + var notificationListComponent: FeedNotificationListComponent + private set + + /** + * Returns the status component. + * + * @return The status component of this module + * @since 3.5.0 + */ + var statusComponent: StatusComponent + private set + + /** + * Returns the handler for loading dialog. + * + * @return Loading dialog handler to be used in this module + * @since 3.5.0 + */ + var loadingDialogHandler: LoadingDialogHandler? = null + private set + + init { + headerComponent = FeedNotificationHeaderComponent(uiConfig).apply { + params.setUseRightButton(false) + params.setUseLeftButton(false) + } + notificationListComponent = FeedNotificationListComponent(uiConfig = uiConfig) + statusComponent = StatusComponent() + } + + override fun onCreateView(context: Context, inflater: LayoutInflater, args: Bundle?): View { + args?.let { params.applyArgs(context, it) } + val moduleContext: Context = ContextThemeWrapper(context, params.theme) + val parent = LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + layoutParams = + LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) + } + + val values = TypedValue() + if (params.shouldUseHeader()) { + moduleContext.theme.resolveAttribute(R.attr.sb_component_header, values, true) + val headerThemeContext: Context = ContextThemeWrapper(moduleContext, values.resourceId) + val headerInflater = inflater.cloneInContext(headerThemeContext) + val header = headerComponent.onCreateView(headerThemeContext, headerInflater, parent, args) + parent.addView(header) + } + + val innerContainer = FrameLayout(context).apply { + layoutParams = + FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) + } + parent.addView(innerContainer) + + moduleContext.theme.resolveAttribute(R.attr.sb_component_list, values, true) + val listThemeContext: Context = ContextThemeWrapper(moduleContext, values.resourceId) + val listInflater = inflater.cloneInContext(listThemeContext) + val channelListLayout = + notificationListComponent.onCreateView(listThemeContext, listInflater, innerContainer, args) + innerContainer.addView(channelListLayout) + moduleContext.theme.resolveAttribute(R.attr.sb_component_status, values, true) + + val statusThemeContext: Context = ContextThemeWrapper(moduleContext, values.resourceId) + val statusInflater = inflater.cloneInContext(statusThemeContext) + val statusLayout = statusComponent.onCreateView(statusThemeContext, statusInflater, innerContainer, args) + innerContainer.addView(statusLayout) + return parent + } + + /** + * Sets the handler for the loading dialog. + * + * @param loadingDialogHandler Loading dialog handler to be used in this module + * @since 3.5.0 + */ + fun setOnLoadingDialogHandler(loadingDialogHandler: LoadingDialogHandler?) { + this.loadingDialogHandler = loadingDialogHandler + } + + /** + * It will be called when the loading dialog needs displaying. + * + * @return True if the callback has consumed the event, false otherwise. + * @since 3.5.0 + */ + fun shouldShowLoadingDialog(): Boolean { + return loadingDialogHandler?.shouldShowLoadingDialog() ?: false + // Do nothing on the channel. + } + + /** + * It will be called when the loading dialog needs dismissing. + * + * @since 3.5.0 + */ + fun shouldDismissLoadingDialog() { + loadingDialogHandler?.shouldDismissLoadingDialog() + } + + class Params : BaseModule.Params { + @JvmOverloads + constructor(context: Context, themeMode: ThemeMode = SendbirdUIKit.getDefaultThemeMode()) : super( + context, + themeMode, + R.attr.sb_module_feed_notification_channel + ) + + /** + * Constructor + * + * @param context The `Context` this module is currently associated with + * @param themeResId The theme resource ID to be applied to this module + * @since 3.5.0 + */ + constructor(context: Context, @StyleRes themeResId: Int) : super( + context, + themeResId, + R.attr.sb_module_feed_notification_channel + ) + + fun applyArgs(context: Context, args: Bundle): Params { + return super.apply(context, args) as Params + } + } +} diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/ui/notifications/FeedNotificationHeaderComponent.kt b/uikit/src/main/java/com/sendbird/uikit/internal/ui/notifications/FeedNotificationHeaderComponent.kt new file mode 100644 index 00000000..8fa8950f --- /dev/null +++ b/uikit/src/main/java/com/sendbird/uikit/internal/ui/notifications/FeedNotificationHeaderComponent.kt @@ -0,0 +1,88 @@ +package com.sendbird.uikit.internal.ui.notifications + +import android.content.Context +import android.content.res.ColorStateList +import android.os.Bundle +import android.util.TypedValue +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.sendbird.android.channel.FeedChannel +import com.sendbird.uikit.internal.model.notifications.NotificationConfig +import com.sendbird.uikit.internal.ui.components.HeaderView +import com.sendbird.uikit.modules.components.HeaderComponent + +/** + * This class creates and performs a view corresponding the channel header area in Sendbird UIKit. + * + * @since 3.5.0 + */ +@JvmSuppressWildcards +internal class FeedNotificationHeaderComponent( + private val uiConfig: NotificationConfig? = null +) : HeaderComponent(Params()) { + /** + * Returns a collection of parameters applied to this component. + * + * @return `Params` applied to this component + * @since 3.5.0 + */ + override fun getParams(): Params { + return super.getParams() as Params + } + + /** + * Called after the component was created to make views. + * + * **If this function is used override, [.getRootView] must also be override.** + * + * @param context The `Context` this component is currently associated with + * @param inflater The LayoutInflater object that can be used to inflate any views in the component + * @param parent The ViewGroup into which the new View will be added + * @param args The arguments supplied when the component was instantiated, if any + * @return Return the View for the UI. + * @since 3.5.0 + */ + override fun onCreateView(context: Context, inflater: LayoutInflater, parent: ViewGroup, args: Bundle?): View { + val layout = super.onCreateView(context, inflater, parent, args) + if (layout is HeaderView) { + layout.descriptionTextView.visibility = View.GONE + + uiConfig?.let { + val themeMode = it.themeMode + it.theme.headerTheme.apply { + layout.setBackgroundColor(backgroundColor.getColor(themeMode)) + layout.setDividerColor(lineColor.getColor(themeMode)) + layout.titleTextView.setTextSize(TypedValue.COMPLEX_UNIT_SP, textSize.toFloat()) + layout.titleTextView.setTextColor(textColor.getColor(themeMode)) + layout.leftButton.imageTintList = ColorStateList.valueOf(buttonIconTintColor.getColor(themeMode)) + } + } + } + return layout + } + + /**Í + * Notifies this component that the channel data has changed. + * + * @param channel The latest group channel + * @since 3.5.0 + */ + fun notifyChannelChanged(channel: FeedChannel) { + val rootView = rootView as? HeaderView ?: return + if (params.title == null) { + rootView.titleTextView.text = channel.name + } + } + + /** + * A collection of parameters, which can be applied to a default View. The values of params are not dynamically applied at runtime. + * Params cannot be created directly, and it is automatically created together when components are created. + * + * **Since the onCreateView configuring View uses the values of the set Params, we recommend that you set up for Params before the onCreateView is called.** + * + * @see .getParams + * @since 3.5.0 + */ + class Params : HeaderComponent.Params() +} diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/ui/notifications/FeedNotificationListAdapter.kt b/uikit/src/main/java/com/sendbird/uikit/internal/ui/notifications/FeedNotificationListAdapter.kt new file mode 100644 index 00000000..d55bff01 --- /dev/null +++ b/uikit/src/main/java/com/sendbird/uikit/internal/ui/notifications/FeedNotificationListAdapter.kt @@ -0,0 +1,179 @@ +package com.sendbird.uikit.internal.ui.notifications + +import android.content.Context +import android.util.TypedValue +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.appcompat.view.ContextThemeWrapper +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import com.sendbird.android.channel.FeedChannel +import com.sendbird.android.message.BaseMessage +import com.sendbird.uikit.R +import com.sendbird.uikit.SendbirdUIKit +import com.sendbird.uikit.activities.viewholder.MessageType +import com.sendbird.uikit.databinding.SbViewFeedNotificationBinding +import com.sendbird.uikit.interfaces.OnMessageListUpdateHandler +import com.sendbird.uikit.interfaces.OnNotificationTemplateActionHandler +import com.sendbird.uikit.internal.model.NotificationDiffCallback +import com.sendbird.uikit.internal.model.notifications.NotificationConfig +import com.sendbird.uikit.internal.ui.viewholders.FeedNotificationViewHolder +import java.util.Collections +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors + +internal class FeedNotificationListAdapter( + private var channel: FeedChannel, + private val notificationConfig: NotificationConfig? +) : RecyclerView.Adapter() { + private var messageList: List = listOf() + private var prevLastSeenAt = 0L + private var currentLastSeenAt: Long = 0 + + // the worker must be a single thread. + private val differWorker by lazy { Executors.newSingleThreadExecutor() } + var onMessageTemplateActionHandler: OnNotificationTemplateActionHandler? = null + set(value) { + field = value + notificationConfig?.onMessageTemplateActionHandler = onMessageTemplateActionHandler + } + + init { + this.currentLastSeenAt = channel.myLastRead + } + + /** + * Called when RecyclerView needs a new [FeedNotificationViewHolder] of the given type to represent + * an item. + * + * @param parent The ViewGroup into which the new View will be added after it is bound to + * an adapter position. + * @param viewType The view type of the new View. + * @return A new [FeedNotificationViewHolder] that holds a View of the given view type. + * @see .getItemViewType + * @see .onBindViewHolder + * @since 3.5.0 + */ + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FeedNotificationViewHolder { + val values = TypedValue() + parent.context.theme.resolveAttribute(R.attr.sb_component_list, values, true) + val contextWrapper: Context = ContextThemeWrapper(parent.context, values.resourceId) + val inflater = LayoutInflater.from(contextWrapper) + return FeedNotificationViewHolder(SbViewFeedNotificationBinding.inflate(inflater, parent, false)) + } + + /** + * Called by RecyclerView to display the data at the specified position. This method should + * update the contents of the [FeedNotificationViewHolder.itemView] to reflect the item at the given + * position. + * + * @param holder The [FeedNotificationViewHolder] which should be updated to represent + * the contents of the item at the given position in the data set. + * @param position The position of the item within the adapter's data set. + * @since 3.5.0 + */ + override fun onBindViewHolder(holder: FeedNotificationViewHolder, position: Int) { + holder.bind(channel, getItem(position), currentLastSeenAt, notificationConfig) + } + + /** + * Return the view type of the [FeedNotificationViewHolder]. + * Notification channel always returns [MessageType.VIEW_TYPE_FEED_NOTIFICATION] + * + * @param position position to query + * @return integer value identifying the type of the view needed to represent the item at `position`. + * @see MessageViewHolderFactory.itemViewType + * @since 3.5.0 + */ + override fun getItemViewType(position: Int): Int { + return MessageType.VIEW_TYPE_FEED_NOTIFICATION.value + } + + /** + * Return ID for the message at `position`. + * + * @param position Adapter position to query + * @return the stable ID of the item at position + * @since 3.5.0 + */ + override fun getItemId(position: Int): Long { + return getItem(position).messageId + } + + /** + * Sets the [<] to be displayed. + * + * @param messageList list to be displayed + * @since 3.5.0 + */ + fun setItems(channel: FeedChannel, messageList: List, callback: OnMessageListUpdateHandler?) { + val copiedChannel = FeedChannel.clone(channel) + val copiedMessage = Collections.unmodifiableList(messageList) + differWorker.submit { + val lock = CountDownLatch(1) + val diffCallback = NotificationDiffCallback( + this@FeedNotificationListAdapter.messageList, + messageList, + prevLastSeenAt, + currentLastSeenAt + ) + val diffResult = DiffUtil.calculateDiff(diffCallback) + SendbirdUIKit.runOnUIThread { + try { + this@FeedNotificationListAdapter.messageList = copiedMessage + this@FeedNotificationListAdapter.channel = copiedChannel + diffResult.dispatchUpdatesTo(this@FeedNotificationListAdapter) + callback?.onListUpdated(messageList) + } finally { + lock.countDown() + } + } + lock.await() + true + } + } + + /** + * Returns the total number of items in the data set held by the adapter. + * + * @return The total number of items in this adapter. + * @since 3.5.0 + */ + override fun getItemCount(): Int { + return messageList.size + } + + /** + * Returns the [BaseMessage] in the data set held by the adapter. + * + * @param position The position of the item within the adapter's data set. + * @return The [BaseMessage] to retrieve the position of in this adapter. + * @since 3.5.0 + */ + fun getItem(position: Int): BaseMessage { + return messageList[position] + } + + /** + * Returns the [<] in the data set held by the adapter. + * + * @return The [<] in this adapter. + * @since 3.5.0 + */ + fun getItems(): List { + return Collections.unmodifiableList(messageList) + } + + /** + * Set the current user's last read timestamp in channel. + * + * @param lastSeenAt the current user's last read timestamp in channel. + * @since 3.5.0 + */ + @Synchronized + fun updateLastSeenAt(lastSeenAt: Long) { + // set the previous lastSeenAt value due to compare the message changing status. + prevLastSeenAt = currentLastSeenAt + currentLastSeenAt = lastSeenAt + } +} diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/ui/notifications/FeedNotificationListComponent.kt b/uikit/src/main/java/com/sendbird/uikit/internal/ui/notifications/FeedNotificationListComponent.kt new file mode 100644 index 00000000..7663b7c3 --- /dev/null +++ b/uikit/src/main/java/com/sendbird/uikit/internal/ui/notifications/FeedNotificationListComponent.kt @@ -0,0 +1,126 @@ +package com.sendbird.uikit.internal.ui.notifications + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.sendbird.android.channel.FeedChannel +import com.sendbird.android.message.BaseMessage +import com.sendbird.uikit.interfaces.OnMessageListUpdateHandler +import com.sendbird.uikit.interfaces.OnNotificationTemplateActionHandler +import com.sendbird.uikit.internal.model.notifications.NotificationConfig +import com.sendbird.uikit.internal.ui.widgets.InnerLinearLayoutManager +import com.sendbird.uikit.model.Action + +/** + * This class creates and performs a view corresponding the notification message list area in Sendbird UIKit. + * + * @since 3.5.0 + */ +@JvmSuppressWildcards +internal open class FeedNotificationListComponent @JvmOverloads constructor( + params: Params = Params(), + uiConfig: NotificationConfig? = null +) : NotificationListComponent(params, uiConfig) { + /** + * Returns the feed notification list adapter. + * + * @return The adapter applied to this list component + * @since 3.5.0 + */ + private var adapter: FeedNotificationListAdapter? = null + private set(value) { + field = value + notificationListView?.recyclerView?.let { + if (value?.onMessageTemplateActionHandler == null) { + value?.onMessageTemplateActionHandler = + OnNotificationTemplateActionHandler { view: View, action: Action, message: BaseMessage -> + onMessageTemplateActionClicked( + view, + action, + message + ) + } + } + it.adapter = value + } + } + + override fun onCreateView(context: Context, inflater: LayoutInflater, parent: ViewGroup, args: Bundle?): View { + val layout = super.onCreateView(context, inflater, parent, args) + val layoutManager = InnerLinearLayoutManager(context).apply { reverseLayout = false } + notificationListView?.recyclerView?.layoutManager = layoutManager + return layout + } + + /** + * Sets the last seen timestamp to update new badge UI. + * This value is used to compare whether a message has been newly received. + * + * @param lastSeenAt the timestamp last viewed by the user. + * @since 3.5.0 + */ + @SuppressLint("NotifyDataSetChanged") + fun notifyLastSeenUpdated(lastSeenAt: Long) { + adapter?.let { + it.updateLastSeenAt(lastSeenAt) + it.notifyDataSetChanged() + } + } + + + /** + * Handles a new channel when data has changed. + * + * @param channel The latest group channel + * @since 3.5.0 + */ + fun notifyChannelChanged(channel: FeedChannel) { + if (adapter == null) { + adapter = FeedNotificationListAdapter(channel, uiConfig) + } + } + + /** + * Handles the data needed to draw the message list has changed. + * + * @param notificationList The list of messages to be drawn + * @param channel The latest group channel + * @param callback Callback when the message list is updated + * @since 3.5.0 + */ + fun notifyDataSetChanged( + notificationList: List, + channel: FeedChannel, + callback: OnMessageListUpdateHandler? + ) { + notificationListView?.let { + adapter?.setItems(channel, notificationList, callback) + } + } + + /** + * A collection of parameters, which can be applied to a default View. The values of params are not dynamically applied at runtime. + * Params cannot be created directly, and it is automatically created together when components are created. + * + * **Since the onCreateView configuring View uses the values of the set Params, we recommend that you set up for Params before the onCreateView is called.** + * + * @see .getParams + * @since 3.5.0 + */ + open class Params : NotificationListComponent.Params() { + /** + * Apply data that matches keys mapped to Params' properties. + * + * @param context The `Context` this component is currently associated with + * @param args The sets of arguments to apply at Params. + * @return This Params object that applied with given data. + * @since 3.5.0 + */ + override fun apply(context: Context, args: Bundle): Params { + return this + } + } +} diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/ui/notifications/NotificationListComponent.kt b/uikit/src/main/java/com/sendbird/uikit/internal/ui/notifications/NotificationListComponent.kt new file mode 100644 index 00000000..1d0e5cf0 --- /dev/null +++ b/uikit/src/main/java/com/sendbird/uikit/internal/ui/notifications/NotificationListComponent.kt @@ -0,0 +1,193 @@ +package com.sendbird.uikit.internal.ui.notifications + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.View.OnClickListener +import android.view.ViewGroup +import com.sendbird.android.message.BaseMessage +import com.sendbird.uikit.R +import com.sendbird.uikit.fragments.ItemAnimator +import com.sendbird.uikit.interfaces.OnNotificationTemplateActionHandler +import com.sendbird.uikit.interfaces.OnPagedDataLoader +import com.sendbird.uikit.internal.model.notifications.NotificationConfig +import com.sendbird.uikit.internal.ui.widgets.NotificationRecyclerView +import com.sendbird.uikit.internal.ui.widgets.PagerRecyclerView.OnScrollEndDetectListener +import com.sendbird.uikit.internal.ui.widgets.PagerRecyclerView.ScrollDirection +import com.sendbird.uikit.model.Action +import java.util.Locale +import java.util.concurrent.atomic.AtomicInteger + +/** + * This class creates and performs a view corresponding the notification message list area in Sendbird UIKit. + * + * @since 3.5.0 + */ +@JvmSuppressWildcards +internal open class NotificationListComponent @JvmOverloads constructor( + private val params: Params = Params(), protected val uiConfig: NotificationConfig? = null +) { + private val tooltipCount = AtomicInteger() + protected var notificationListView: NotificationRecyclerView? = null + + var onTooltipClickListener: OnClickListener? = null + set(value) { + field = value + notificationListView?.setOnTooltipClickListener(value) + } + + var onMessageTemplateActionHandler: OnNotificationTemplateActionHandler? = null + + var pagedDataLoader: OnPagedDataLoader>? = null + set(value) { + field = value + pagedDataLoader?.let { notificationListView?.recyclerView?.setPager(it) } + } + + /** + * Returns the view created by [.onCreateView]. + * + * @return the topmost view containing this view + * @since 3.5.0 + */ + val rootView: View? + get() = notificationListView + + /** + * Called after the component was created to make views. + * + * **If this function is used override, [.getRootView] must also be override.** + * + * @param context The `Context` this component is currently associated with + * @param inflater The LayoutInflater object that can be used to inflate any views in the component + * @param parent The ViewGroup into which the new View will be added + * @param args The arguments supplied when the component was instantiated, if any + * @return Return the View for the UI. + * @since 3.5.0 + */ + open fun onCreateView(context: Context, inflater: LayoutInflater, parent: ViewGroup, args: Bundle?): View { + if (args != null) params.apply(context, args) + + val layout = NotificationRecyclerView(context, null, R.attr.sb_component_list).apply { + recyclerView.setHasFixedSize(true) + recyclerView.clipToPadding = false + recyclerView.setThreshold(5) + recyclerView.setUseDivider(false) + recyclerView.itemAnimator = ItemAnimator() + recyclerView.useReverseData() + recyclerView.setOnScrollEndDetectListener(object : OnScrollEndDetectListener { + override fun onScrollEnd(direction: ScrollDirection) { + onScrollEndReaches( + direction, this@apply + ) + } + }) + } + + uiConfig?.let { + val themeMode = it.themeMode + it.theme.listTheme.apply { + layout.setBackgroundColor(backgroundColor.getColor(themeMode)) + layout.setTooltipBackgroundColor(tooltip.backgroundColor.getColor(themeMode)) + layout.setTooltipTextColor(tooltip.textColor.getColor(themeMode)) + } + } + notificationListView = layout + return layout + } + + /** + * Called when the view that has an [com.sendbird.uikit.model.Action] data is clicked. + * + * @param view the view that was clicked. + * @param action the registered Action data + * @param message the clicked message + * @since 3.5.0 + */ + protected fun onMessageTemplateActionClicked(view: View, action: Action, message: BaseMessage) { + onMessageTemplateActionHandler?.onHandleAction(view, action, message) + } + + /** + * Scrolls to the first position of the recycler view. + */ + fun scrollToFirst() { + notificationListView?.recyclerView?.stopScroll() + notificationListView?.recyclerView?.scrollToPosition(0) + } + + private fun onScrollEndReaches(direction: ScrollDirection, notificationListView: NotificationRecyclerView) { + val reverseLayout = notificationListView.isReverseLayout + if (reverseLayout && direction === ScrollDirection.Bottom || !reverseLayout && direction === ScrollDirection.Top) { + tooltipCount.set(0) + notificationListView.hideTooltip() + } + } + + fun notifyMessagesFilled() { + notificationListView?.let { + val firstVisibleItemPosition: Int = it.recyclerView.findFirstVisibleItemPosition() + if (firstVisibleItemPosition == 0) { + scrollToFirst() + } + } + } + + fun notifyNewNotificationReceived() { + notificationListView?.let { + val firstVisibleItemPosition: Int = it.recyclerView.findFirstVisibleItemPosition() + if (firstVisibleItemPosition > 0) { + it.showTooltip( + getTooltipText( + it.context, tooltipCount.incrementAndGet() + ) + ) + return + } + if (firstVisibleItemPosition == 0) { + scrollToFirst() + } + } + } + + /** + * Returns the text on the tooltip. + * + * @param context The `Context` this view is currently associated with + * @param count Number of new messages + * @return Text to be shown on the tooltip + */ + open fun getTooltipText(context: Context, count: Int): String { + return notificationListView?.let { + "${ + String.format( + Locale.getDefault(), context.getString(R.string.sb_text_channel_tooltip), count + ) + }${if (count > 1) "s" else ""}" + } ?: "" + } + + /** + * A collection of parameters, which can be applied to a default View. The values of params are not dynamically applied at runtime. + * Params cannot be created directly, and it is automatically created together when components are created. + * + * **Since the onCreateView configuring View uses the values of the set Params, we recommend that you set up for Params before the onCreateView is called.** + * + * @see .getParams + * @since 3.5.0 + */ + open class Params { + /** + * Apply data that matches keys mapped to Params' properties. + * + * @param context The `Context` this component is currently associated with + * @param args The sets of arguments to apply at Params. + * @return This Params object that applied with given data. + * @since 3.5.0 + */ + open fun apply(context: Context, args: Bundle): Params { + return this + } + } +} diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/ui/viewholders/ChatNotificationViewHolder.kt b/uikit/src/main/java/com/sendbird/uikit/internal/ui/viewholders/ChatNotificationViewHolder.kt new file mode 100644 index 00000000..92b39bbd --- /dev/null +++ b/uikit/src/main/java/com/sendbird/uikit/internal/ui/viewholders/ChatNotificationViewHolder.kt @@ -0,0 +1,16 @@ +package com.sendbird.uikit.internal.ui.viewholders + +import com.sendbird.android.channel.BaseChannel +import com.sendbird.android.message.BaseMessage +import com.sendbird.uikit.databinding.SbViewChatNotificationBinding +import com.sendbird.uikit.internal.model.notifications.NotificationConfig + +internal class ChatNotificationViewHolder internal constructor( + val binding: SbViewChatNotificationBinding +) : NotificationViewHolder(binding.root) { + + override fun bind(channel: BaseChannel, message: BaseMessage, config: NotificationConfig?) { + binding.chatNotification.onNotificationTemplateActionHandler = config?.onMessageTemplateActionHandler + binding.chatNotification.drawMessage(channel, message, config) + } +} diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/ui/viewholders/FeedNotificationViewHolder.kt b/uikit/src/main/java/com/sendbird/uikit/internal/ui/viewholders/FeedNotificationViewHolder.kt new file mode 100644 index 00000000..7bab4333 --- /dev/null +++ b/uikit/src/main/java/com/sendbird/uikit/internal/ui/viewholders/FeedNotificationViewHolder.kt @@ -0,0 +1,17 @@ +package com.sendbird.uikit.internal.ui.viewholders + +import androidx.recyclerview.widget.RecyclerView +import com.sendbird.android.channel.FeedChannel +import com.sendbird.android.message.BaseMessage +import com.sendbird.uikit.databinding.SbViewFeedNotificationBinding +import com.sendbird.uikit.internal.model.notifications.NotificationConfig + +internal class FeedNotificationViewHolder internal constructor( + val binding: SbViewFeedNotificationBinding +) : RecyclerView.ViewHolder(binding.root) { + + fun bind(channel: FeedChannel, message: BaseMessage, lastSeenAt: Long, config: NotificationConfig?) { + binding.feedNotification.onNotificationTemplateActionHandler = config?.onMessageTemplateActionHandler + binding.feedNotification.drawMessage(message, lastSeenAt, config) + } +} diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/ui/viewholders/NotificationTimelineViewHolder.kt b/uikit/src/main/java/com/sendbird/uikit/internal/ui/viewholders/NotificationTimelineViewHolder.kt new file mode 100644 index 00000000..9379b10f --- /dev/null +++ b/uikit/src/main/java/com/sendbird/uikit/internal/ui/viewholders/NotificationTimelineViewHolder.kt @@ -0,0 +1,15 @@ +package com.sendbird.uikit.internal.ui.viewholders + +import com.sendbird.android.channel.BaseChannel +import com.sendbird.android.message.BaseMessage +import com.sendbird.uikit.databinding.SbViewTimeLineMessageBinding +import com.sendbird.uikit.internal.model.notifications.NotificationConfig + +internal class NotificationTimelineViewHolder internal constructor( + val binding: SbViewTimeLineMessageBinding, +) : NotificationViewHolder(binding.root) { + + override fun bind(channel: BaseChannel, message: BaseMessage, uiConfig: NotificationConfig?) { + binding.timelineMessageView.drawTimeline(message, uiConfig) + } +} diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/ui/viewholders/NotificationViewHolder.kt b/uikit/src/main/java/com/sendbird/uikit/internal/ui/viewholders/NotificationViewHolder.kt new file mode 100644 index 00000000..166115ac --- /dev/null +++ b/uikit/src/main/java/com/sendbird/uikit/internal/ui/viewholders/NotificationViewHolder.kt @@ -0,0 +1,14 @@ +package com.sendbird.uikit.internal.ui.viewholders + +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import com.sendbird.android.channel.BaseChannel +import com.sendbird.android.message.BaseMessage +import com.sendbird.uikit.internal.model.notifications.NotificationConfig + +internal abstract class NotificationViewHolder internal constructor( + view: View +) : RecyclerView.ViewHolder(view) { + + abstract fun bind(channel: BaseChannel, message: BaseMessage, config: NotificationConfig?) +} diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/ui/widgets/MessageTemplateImageView.kt b/uikit/src/main/java/com/sendbird/uikit/internal/ui/widgets/MessageTemplateImageView.kt new file mode 100644 index 00000000..c3bc464e --- /dev/null +++ b/uikit/src/main/java/com/sendbird/uikit/internal/ui/widgets/MessageTemplateImageView.kt @@ -0,0 +1,169 @@ +package com.sendbird.uikit.internal.ui.widgets + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Path +import android.graphics.RectF +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.view.ViewGroup +import android.widget.ImageView +import androidx.annotation.ColorInt +import androidx.appcompat.widget.AppCompatImageView +import com.bumptech.glide.Glide +import com.bumptech.glide.load.resource.gif.GifDrawable +import com.bumptech.glide.request.target.CustomViewTarget +import com.bumptech.glide.request.transition.Transition +import com.sendbird.uikit.internal.extensions.intToDp +import com.sendbird.uikit.internal.interfaces.ViewRoundable +import com.sendbird.uikit.internal.model.template_messages.SizeType +import com.sendbird.uikit.internal.model.template_messages.ViewParams +import com.sendbird.uikit.log.Logger +import com.sendbird.uikit.utils.MetricsUtils + +internal open class MessageTemplateImageView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : AppCompatImageView(context, attrs, defStyleAttr), ViewRoundable { + private lateinit var rectF: RectF + private val path: Path = Path() + private var strokePaint: Paint? = null + override var radius: Float = 0F + private var imageRatio: Float = 0F + private var targetWidth: Int = 0 + private var targetHeight: Int = 0 + var viewParams: ViewParams? = null + + fun setSize(width: Int, height: Int) { + if (width > 0 && height > 0) { + this.targetWidth = width + this.targetHeight = height + this.imageRatio = width.toFloat() / height.toFloat() + requestLayout() + } + } + + init { + setBorder(0, Color.TRANSPARENT) + } + + override fun setRadiusIntSize(radius: Int) { + this.radius = context.resources.intToDp(radius).toFloat() + } + + final override fun setBorder(borderWidth: Int, @ColorInt borderColor: Int) { + if (borderWidth <= 0) + strokePaint = null + else { + strokePaint = Paint().apply { + style = Paint.Style.STROKE + isAntiAlias = true + strokeWidth = context.resources.intToDp(borderWidth).toFloat() + color = borderColor + } + } + } + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + rectF = RectF(0f, 0f, w.toFloat(), h.toFloat()) + resetPath() + } + + override fun draw(canvas: Canvas) { + val save = canvas.save() + canvas.clipPath(path) + super.draw(canvas) + strokePaint?.let { canvas.drawRoundRect(rectF, radius, radius, it) } + canvas.restoreToCount(save) + } + + override fun dispatchDraw(canvas: Canvas) { + val save = canvas.save() + canvas.clipPath(path) + super.dispatchDraw(canvas) + strokePaint?.let { canvas.drawRoundRect(rectF, radius, radius, it) } + canvas.restoreToCount(save) + } + + private fun resetPath() { + path.reset() + path.addRoundRect(rectF, radius, radius, Path.Direction.CW) + path.close() + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + + val width = MeasureSpec.getSize(widthMeasureSpec) + if (width == 0) { + return + } + + // auto image resolution applies only the height value is flexible + viewParams?.let { + if (it.height.type == SizeType.Fixed) return + if (it.height.type == SizeType.Flex && it.height.value == ViewGroup.LayoutParams.MATCH_PARENT) return + } ?: return + + // if imageRatio doesn't know it should skip + if (imageRatio == 0f) { + return + } + + val layoutParams = layoutParams as ViewGroup.MarginLayoutParams + val newHeight = (width / imageRatio).toInt() + layoutParams.height = newHeight + setMeasuredDimension(width, newHeight) + } + + fun load(url: String) { + var glide = Glide.with(context).load(url) + if (targetWidth > 0 && targetHeight > 0) { + val deviceWidth = MetricsUtils.getDeviceWidth(context) + if (targetWidth > deviceWidth) { + val height = (deviceWidth / imageRatio).toInt() + glide = glide.override(deviceWidth, height) + Logger.i("++ override width=$deviceWidth, height=$height, url=$url") + } + } + + if (imageRatio > 0) { + // if the ratio exist no need to get the original image size to resize image. + glide.into(this) + } else { + // if the image ratio not exist it need to find the size of image to make imageview size. + // After obtaining the proportion of the image, onMeasure is used to determine the size of the view to match the proportion. + glide.into(object : CustomViewTarget(this) { + override fun onLoadFailed(errorDrawable: Drawable?) {} + override fun onResourceCleared(placeholder: Drawable?) {} + + override fun onResourceReady( + resource: Drawable, + transition: Transition? + ) { + val width = when (resource) { + is BitmapDrawable -> resource.bitmap.width + is GifDrawable -> resource.intrinsicWidth + else -> 0 + } + + val height = when (resource) { + is BitmapDrawable -> resource.bitmap.height + is GifDrawable -> resource.intrinsicHeight + else -> 0 + } + + Logger.i("++ width=$width, height=$height, url=$url") + setSize(width, height) + setImageDrawable(resource) + } + + }) + } + } +} diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/ui/widgets/NotificationRecyclerView.kt b/uikit/src/main/java/com/sendbird/uikit/internal/ui/widgets/NotificationRecyclerView.kt new file mode 100644 index 00000000..832fd046 --- /dev/null +++ b/uikit/src/main/java/com/sendbird/uikit/internal/ui/widgets/NotificationRecyclerView.kt @@ -0,0 +1,104 @@ +package com.sendbird.uikit.internal.ui.widgets + +import android.content.Context +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.View +import android.widget.FrameLayout +import androidx.annotation.ColorInt +import androidx.constraintlayout.widget.ConstraintSet +import com.sendbird.uikit.R +import com.sendbird.uikit.databinding.SbViewChatNotificationRecyclerViewBinding +import com.sendbird.uikit.internal.extensions.addRipple +import com.sendbird.uikit.internal.extensions.intToDp +import com.sendbird.uikit.internal.extensions.setAppearance +import com.sendbird.uikit.utils.SoftInputUtils + +internal class NotificationRecyclerView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0 +) : FrameLayout(context, attrs, defStyle) { + private val binding: SbViewChatNotificationRecyclerViewBinding + val recyclerView: PagerRecyclerView + get() = binding.rvMessageList + val isReverseLayout + get() = recyclerView.isReverseLayout() + + override fun setBackground(background: Drawable?) { + recyclerView.background = background + } + + fun showTooltip(text: String) { + binding.vgTooltipBox.visibility = VISIBLE + binding.tooltip.text = text + movePosition() + } + + fun hideTooltip() { + binding.vgTooltipBox.visibility = GONE + } + + fun setTooltipTextColor(@ColorInt color: Int) { + binding.tooltip.setTextColor(color) + } + + fun setTooltipBackgroundColor(@ColorInt color: Int) { + binding.vgTooltipBox.setBackgroundColor(color) + } + + fun setOnTooltipClickListener(onTooltipClickListener: OnClickListener?) { + binding.vgTooltipBox.setOnClickListener(onTooltipClickListener) + } + + private fun movePosition() { + val reverse = recyclerView.isReverseLayout() + val set = ConstraintSet() + val rootView = binding.root + set.clone(rootView) + if (reverse) { + set.clear(binding.vgTooltipBox.id, ConstraintSet.TOP) + set.connect(binding.vgTooltipBox.id, ConstraintSet.BOTTOM, ConstraintSet.PARENT_ID, ConstraintSet.BOTTOM) + } else { + set.clear(binding.vgTooltipBox.id, ConstraintSet.BOTTOM) + set.connect(binding.vgTooltipBox.id, ConstraintSet.TOP, ConstraintSet.PARENT_ID, ConstraintSet.TOP) + } + set.applyTo(rootView) + } + + init { + val a = context.theme.obtainStyledAttributes(attrs, R.styleable.NotificationListView, defStyle, 0) + try { + binding = SbViewChatNotificationRecyclerViewBinding.inflate(LayoutInflater.from(getContext()), this, true) + val recyclerViewBackground = + a.getResourceId( + R.styleable.NotificationListView_sb_notification_recyclerview_background, + R.color.background_50 + ) + val tooltipBackground = a.getResourceId( + R.styleable.NotificationListView_sb_notification_recyclerview_tooltip_background, + R.drawable.selector_tooltip_background_light + ) + val tooltipTextAppearance = a.getResourceId( + R.styleable.NotificationListView_sb_notification_recyclerview_tooltip_text_appearance, + R.style.SendbirdBody2OnDark01 + ) + setBackgroundResource(android.R.color.transparent) + binding.rvMessageList.setOnTouchListener { v: View, _: MotionEvent? -> + SoftInputUtils.hideSoftKeyboard(this) + v.performClick() + false + } + binding.rvMessageList.setBackgroundResource(recyclerViewBackground) + binding.rvMessageList.setUseDivider(false) + binding.vgTooltipBox.radius = resources.intToDp(19).toFloat() + binding.vgTooltipBox.setBackgroundResource(tooltipBackground) + binding.vgTooltipBox.addRipple() + binding.tooltip.setAppearance(context, tooltipTextAppearance) + } finally { + a.recycle() + } + } +} diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/ui/widgets/PagerRecyclerView.kt b/uikit/src/main/java/com/sendbird/uikit/internal/ui/widgets/PagerRecyclerView.kt index 74ac86a1..7d9de643 100755 --- a/uikit/src/main/java/com/sendbird/uikit/internal/ui/widgets/PagerRecyclerView.kt +++ b/uikit/src/main/java/com/sendbird/uikit/internal/ui/widgets/PagerRecyclerView.kt @@ -60,6 +60,10 @@ internal class PagerRecyclerView @JvmOverloads constructor( return layoutManager?.findLastVisibleItemPosition() ?: 0 } + fun isReverseLayout(): Boolean { + return layoutManager?.reverseLayout ?: false + } + fun setOnScrollEndDetectListener(scrollEndDetectListener: OnScrollEndDetectListener?) { onScrollListener.scrollEndDetectListener = scrollEndDetectListener } @@ -78,6 +82,10 @@ internal class PagerRecyclerView @JvmOverloads constructor( fun getStackFromEnd(): Boolean = layoutManager?.stackFromEnd ?: false + override fun performClick(): Boolean { + return super.performClick() + } + private class OnScrollListener constructor(var layoutManager: LinearLayoutManager?) : RecyclerView.OnScrollListener() { private var threshold = 1 diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/ui/widgets/RoundCornerLayout.kt b/uikit/src/main/java/com/sendbird/uikit/internal/ui/widgets/RoundCornerLayout.kt new file mode 100755 index 00000000..f02079dd --- /dev/null +++ b/uikit/src/main/java/com/sendbird/uikit/internal/ui/widgets/RoundCornerLayout.kt @@ -0,0 +1,73 @@ +package com.sendbird.uikit.internal.ui.widgets + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Path +import android.graphics.RectF +import android.util.AttributeSet +import android.widget.LinearLayout +import androidx.annotation.ColorInt +import com.sendbird.uikit.internal.extensions.intToDp +import com.sendbird.uikit.internal.interfaces.ViewRoundable + +internal open class RoundCornerLayout @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : LinearLayout(context, attrs, defStyleAttr), ViewRoundable { + private lateinit var rectF: RectF + private val path: Path = Path() + private var strokePaint: Paint? = null + override var radius: Float = 0F + + init { + setBorder(0, Color.TRANSPARENT) + } + + override fun setRadiusIntSize(radius: Int) { + this.radius = context.resources.intToDp(radius).toFloat() + } + + final override fun setBorder(borderWidth: Int, @ColorInt borderColor: Int) { + if (borderWidth <= 0) + strokePaint = null + else { + strokePaint = Paint().apply { + style = Paint.Style.STROKE + isAntiAlias = true + strokeWidth = context.resources.intToDp(borderWidth).toFloat() + color = borderColor + } + } + } + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + rectF = RectF(0f, 0f, w.toFloat(), h.toFloat()) + resetPath() + } + + override fun draw(canvas: Canvas) { + val save = canvas.save() + canvas.clipPath(path) + super.draw(canvas) + strokePaint?.let { canvas.drawRoundRect(rectF, radius, radius, it) } + canvas.restoreToCount(save) + } + + override fun dispatchDraw(canvas: Canvas) { + val save = canvas.save() + canvas.clipPath(path) + super.dispatchDraw(canvas) + strokePaint?.let { canvas.drawRoundRect(rectF, radius, radius, it) } + canvas.restoreToCount(save) + } + + private fun resetPath() { + path.reset() + path.addRoundRect(rectF, radius, radius, Path.Direction.CW) + path.close() + } +} diff --git a/uikit/src/main/java/com/sendbird/uikit/internal/ui/widgets/TemplateViews.kt b/uikit/src/main/java/com/sendbird/uikit/internal/ui/widgets/TemplateViews.kt new file mode 100644 index 00000000..08425500 --- /dev/null +++ b/uikit/src/main/java/com/sendbird/uikit/internal/ui/widgets/TemplateViews.kt @@ -0,0 +1,186 @@ +package com.sendbird.uikit.internal.ui.widgets + +import android.annotation.SuppressLint +import android.content.Context +import android.text.TextUtils +import android.util.AttributeSet +import android.view.Gravity +import android.widget.LinearLayout +import android.widget.TextView +import androidx.appcompat.widget.AppCompatTextView +import com.sendbird.uikit.R +import com.sendbird.uikit.internal.extensions.addRipple +import com.sendbird.uikit.internal.extensions.intToDp +import com.sendbird.uikit.internal.extensions.setAppearance +import com.sendbird.uikit.internal.model.template_messages.BoxViewParams +import com.sendbird.uikit.internal.model.template_messages.ButtonViewParams +import com.sendbird.uikit.internal.model.template_messages.ImageButtonViewParams +import com.sendbird.uikit.internal.model.template_messages.ImageViewParams +import com.sendbird.uikit.internal.model.template_messages.Orientation +import com.sendbird.uikit.internal.model.template_messages.TextViewParams + +@SuppressLint("ViewConstructor") +internal open class Text @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : RoundCornerLayout(context, attrs, defStyleAttr) { + private val textView: TextView + + init { + layoutParams = LayoutParams( + LayoutParams.WRAP_CONTENT, + LayoutParams.WRAP_CONTENT + ) + textView = AppCompatTextView(context).apply { + // set default button text appearance + setAppearance(context, R.style.SendbirdBody3OnLight01) + ellipsize = TextUtils.TruncateAt.END + layoutParams = LayoutParams( + LayoutParams.MATCH_PARENT, + LayoutParams.MATCH_PARENT + ) + } + this.addView(textView) + } + + fun apply(params: TextViewParams, orientation: Orientation) { + params.applyLayoutParams(context, layoutParams, orientation) + params.viewStyle.apply(this) + params.textStyle.apply(textView) + + textView.gravity = params.align.gravity + params.maxTextLines?.let { textView.maxLines = it } + textView.text = params.text + } + + fun setTextAppearance(textAppearance: Int) { + textView.setAppearance(context, textAppearance) + } +} + +@SuppressLint("ViewConstructor") +internal open class Image @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : MessageTemplateImageView(context, attrs, defStyleAttr) { + init { + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + scaleType = ScaleType.FIT_CENTER + } + + fun apply(params: ImageViewParams, orientation: Orientation) { + this.viewParams = params + params.applyLayoutParams(context, layoutParams, orientation) + params.metaData?.let { + setSize(it.pixelWidth, it.pixelHeight) + } + params.imageStyle.apply(this) + params.viewStyle.apply(this) + load(params.imageUrl) + } +} + +@SuppressLint("ViewConstructor") +internal open class TextButton @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : RoundCornerLayout(context, attrs, defStyleAttr) { + private val textView: TextView + + init { + // Even if action doesn't exist click ripple effect should show. (UIKit spec) + layoutParams = LayoutParams( + LayoutParams.WRAP_CONTENT, + LayoutParams.WRAP_CONTENT + ) + isClickable = true + gravity = Gravity.CENTER + // default button padding. + val padding = resources.intToDp(10) + this.setPadding(padding, padding, padding, padding) + this.setBackgroundResource(R.drawable.sb_shape_round_rect_background_200) + setRadiusIntSize(6) + addRipple(background) + + textView = AppCompatTextView(context).apply { + // set default button text appearance + setAppearance(context, R.style.SendbirdButtonPrimary300) + + layoutParams = LayoutParams( + LayoutParams.MATCH_PARENT, + LayoutParams.MATCH_PARENT + ) + gravity = Gravity.CENTER + } + this.addView(textView) + } + + fun apply(params: ButtonViewParams, orientation: Orientation) { + params.applyLayoutParams(context, layoutParams, orientation) + params.textStyle.apply(textView) + params.viewStyle.apply(this, true) + textView.maxLines = params.maxTextLines + textView.text = params.text + addRipple(background) + } + + fun setTextAppearance(textAppearance: Int) { + textView.setAppearance(context, textAppearance) + } +} + +@SuppressLint("ViewConstructor") +internal open class ImageButton @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : MessageTemplateImageView(context, attrs, defStyleAttr) { + init { + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + scaleType = ScaleType.FIT_CENTER + setRadiusIntSize(6) + addRipple(background) + } + + fun apply(params: ImageButtonViewParams, orientation: Orientation) { + this.viewParams = params + params.applyLayoutParams(context, layoutParams, orientation) + params.metaData?.let { + setSize(it.pixelWidth, it.pixelHeight) + } + params.imageStyle.apply(this) + params.viewStyle.apply(this, true) + load(params.imageUrl) + addRipple(background) + } +} + +@SuppressLint("ViewConstructor") +internal open class Box @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : RoundCornerLayout(context, attrs, defStyleAttr) { + init { + layoutParams = LayoutParams( + LayoutParams.WRAP_CONTENT, + LayoutParams.WRAP_CONTENT + ) + } + + fun apply(params: BoxViewParams, orientation: Orientation) { + this.orientation = params.orientation.value + params.applyLayoutParams(context, layoutParams, orientation) + gravity = params.align.gravity + params.viewStyle.apply(this) + } +} diff --git a/uikit/src/main/java/com/sendbird/uikit/model/Action.java b/uikit/src/main/java/com/sendbird/uikit/model/Action.java new file mode 100644 index 00000000..cc8961bb --- /dev/null +++ b/uikit/src/main/java/com/sendbird/uikit/model/Action.java @@ -0,0 +1,88 @@ +package com.sendbird.uikit.model; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.sendbird.uikit.internal.model.template_messages.ActionData; + +/** + * Custom action data to be linked to a custom message. + * This action data is delivered and used as it is. + * + * @since 3.5.0 + */ +public final class Action { + private final String type; + private final String data; + private final String alterData; + + /** + * Constructor that is used only internally. + * + * @param type an action data type. + * @param data a data delivered and used as it is + * @param alterData an alternative data that can be used if data is not available + * @since 3.5.0 + */ + public Action(@NonNull String type, @NonNull String data, @Nullable String alterData) { + this.type = type; + this.data = data; + this.alterData = alterData; + } + + /** + * Convert ActionData to Action class. This is used only for internal. + * + * @param actionData The data from the given custom data filed. + * @return Action data. + * @since 3.5.0 + */ + @NonNull + public static Action from(@NonNull ActionData actionData) { + return new Action(actionData.getType().name().toLowerCase(), actionData.getData(), actionData.getAlterData()); + } + + /** + * Returns the type of Action. + * "web", "custom", and "uikit" are available. + * + * @return the type of Action. + * @since 3.5.0 + */ + @NonNull + public String getType() { + return type; + } + + /** + * Returns action data that associated with the view. + * + * @return the action data associated with the view. + * @since 3.5.0 + */ + @NonNull + public String getData() { + return data; + } + + /** + * Alternative data that can be used if data is not available + * + * @return the alternative data that can be used if data is not available + * @since 3.5.0 + */ + @Nullable + public String getAlterData() { + return alterData; + } + + @NonNull + @Override + public String toString() { + return "Action{" + + "type='" + type + '\'' + + ", data='" + data + '\'' + + ", alterData='" + alterData + '\'' + + '}'; + } +} diff --git a/uikit/src/main/java/com/sendbird/uikit/model/MessageData.java b/uikit/src/main/java/com/sendbird/uikit/model/MessageData.java new file mode 100644 index 00000000..4aaa69ba --- /dev/null +++ b/uikit/src/main/java/com/sendbird/uikit/model/MessageData.java @@ -0,0 +1,53 @@ +package com.sendbird.uikit.model; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.sendbird.android.message.BaseMessage; + +import java.util.List; + +/** + * Class that holds message data in a channel. + * + * @since 3.5.0 + */ +public class MessageData { + final List messages; + final String traceName; + + public MessageData(@Nullable String traceName, @NonNull List messages) { + this.traceName = traceName; + this.messages = messages; + } + + /** + * Returns a list of messages for the current channel. + * + * @return A list of the latest messages on the current channel + * @since 3.5.0 + */ + @NonNull + public List getMessages() { + return messages; + } + + /** + * Returns data indicating how the message list was updated. + * + * @return The String that traces the path of the message list + * @since 3.5.0 + */ + @Nullable + public String getTraceName() { + return traceName; + } + + @Override + public String toString() { + return "MessageData{" + + "messages=" + messages + + ", traceName='" + traceName + '\'' + + '}'; + } +} diff --git a/uikit/src/main/java/com/sendbird/uikit/model/MessageListUIParams.java b/uikit/src/main/java/com/sendbird/uikit/model/MessageListUIParams.java index 3c629c4f..14f9160d 100644 --- a/uikit/src/main/java/com/sendbird/uikit/model/MessageListUIParams.java +++ b/uikit/src/main/java/com/sendbird/uikit/model/MessageListUIParams.java @@ -185,13 +185,14 @@ public MessageListUIParams build() { @Override public boolean equals(Object o) { if (this == o) return true; - if (!(o instanceof MessageListUIParams)) return false; + if (o == null || getClass() != o.getClass()) return false; MessageListUIParams that = (MessageListUIParams) o; if (useMessageGroupUI != that.useMessageGroupUI) return false; if (useReverseLayout != that.useReverseLayout) return false; if (useQuotedView != that.useQuotedView) return false; + if (useMessageReceipt != that.useMessageReceipt) return false; return messageGroupType == that.messageGroupType; } @@ -201,6 +202,7 @@ public int hashCode() { result = 31 * result + (useMessageGroupUI ? 1 : 0); result = 31 * result + (useReverseLayout ? 1 : 0); result = 31 * result + (useQuotedView ? 1 : 0); + result = 31 * result + (useMessageReceipt ? 1 : 0); return result; } diff --git a/uikit/src/main/java/com/sendbird/uikit/modules/components/HeaderComponent.java b/uikit/src/main/java/com/sendbird/uikit/modules/components/HeaderComponent.java index d7616ce5..2e7e3e7e 100644 --- a/uikit/src/main/java/com/sendbird/uikit/modules/components/HeaderComponent.java +++ b/uikit/src/main/java/com/sendbird/uikit/modules/components/HeaderComponent.java @@ -41,7 +41,7 @@ public HeaderComponent() { this.params = new Params(); } - HeaderComponent(@NonNull Params params) { + public HeaderComponent(@NonNull Params params) { this.params = params; } diff --git a/uikit/src/main/java/com/sendbird/uikit/modules/components/MessageListComponent.java b/uikit/src/main/java/com/sendbird/uikit/modules/components/MessageListComponent.java index c7c6c887..eed2ad99 100644 --- a/uikit/src/main/java/com/sendbird/uikit/modules/components/MessageListComponent.java +++ b/uikit/src/main/java/com/sendbird/uikit/modules/components/MessageListComponent.java @@ -1,14 +1,11 @@ package com.sendbird.uikit.modules.components; -import android.content.Context; -import android.os.Bundle; -import android.view.LayoutInflater; import android.view.View; -import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import com.sendbird.android.channel.GroupChannel; import com.sendbird.android.message.BaseMessage; import com.sendbird.android.message.SendingStatus; import com.sendbird.uikit.activities.adapter.MessageListAdapter; @@ -49,14 +46,12 @@ public Params getParams() { return (Params) super.getParams(); } - @NonNull @Override - public View onCreateView(@NonNull Context context, @NonNull LayoutInflater inflater, @NonNull ViewGroup parent, @Nullable Bundle args) { - final View view = super.onCreateView(context, inflater, parent, args); + public void notifyChannelChanged(@NonNull GroupChannel channel) { if (getAdapter() == null) { - setAdapter(new MessageListAdapter(null, getParams().shouldUseGroupUI())); + setAdapter(new MessageListAdapter(channel, getParams().shouldUseGroupUI())); } - return view; + super.notifyChannelChanged(channel); } @Override diff --git a/uikit/src/main/java/com/sendbird/uikit/utils/Available.java b/uikit/src/main/java/com/sendbird/uikit/utils/Available.java index f9eae5cf..2c19e50c 100644 --- a/uikit/src/main/java/com/sendbird/uikit/utils/Available.java +++ b/uikit/src/main/java/com/sendbird/uikit/utils/Available.java @@ -3,6 +3,7 @@ import androidx.annotation.NonNull; import com.sendbird.android.AppInfo; +import com.sendbird.android.NotificationInfo; import com.sendbird.android.SendbirdChat; import com.sendbird.uikit.consts.StringSet; @@ -62,4 +63,22 @@ public static boolean isSupportOgTag() { public static boolean isSupportMessageSearch() { return isAvailable(StringSet.message_search_v3); } + + /** + * Checks if the application support chat notification. + * + * @return true if the chat notification is available, false otherwise. + * @since 3.5.0 + */ + public static boolean isSupportChatNotification() { + boolean includeChatNotification = false; + final AppInfo appInfo = SendbirdChat.getAppInfo(); + if (appInfo != null) { + final NotificationInfo notificationInfo = appInfo.getNotificationInfo(); + if (notificationInfo != null) { + includeChatNotification = notificationInfo.isEnabled(); + } + } + return includeChatNotification; + } } diff --git a/uikit/src/main/java/com/sendbird/uikit/utils/DrawableUtils.java b/uikit/src/main/java/com/sendbird/uikit/utils/DrawableUtils.java index 8df36bfa..da7e35ac 100644 --- a/uikit/src/main/java/com/sendbird/uikit/utils/DrawableUtils.java +++ b/uikit/src/main/java/com/sendbird/uikit/utils/DrawableUtils.java @@ -11,11 +11,13 @@ import android.graphics.drawable.ShapeDrawable; import android.graphics.drawable.shapes.OvalShape; +import androidx.annotation.ColorInt; import androidx.annotation.ColorRes; import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.content.res.AppCompatResources; +import androidx.core.content.ContextCompat; import androidx.core.graphics.drawable.DrawableCompat; import com.sendbird.uikit.R; @@ -72,6 +74,13 @@ public static Drawable setTintList(@Nullable Drawable drawable, @Nullable ColorS return mutated; } + @NonNull + public static Drawable createOvalIcon(@NonNull Context context, @ColorRes int color) { + final ShapeDrawable drawable = new ShapeDrawable(new OvalShape()); + drawable.getPaint().setColor(ContextCompat.getColor(context, color)); + return drawable; + } + @NonNull public static Drawable createOvalIcon(@NonNull Context context, @ColorRes int backgroundColor, @DrawableRes int iconRes, @ColorRes int iconTint) { @@ -95,6 +104,15 @@ public static Drawable createOvalIcon(@NonNull Context context, @ColorRes int ba return createLayerIcon(ovalBackground, icon, inset); } + @NonNull + public static Drawable createOvalIconWithInset(@NonNull Context context, @ColorRes int background, @DrawableRes int iconRes, @ColorRes int iconTint, int inset) { + final ShapeDrawable drawable = new ShapeDrawable(new OvalShape()); + drawable.getPaint().setColor(ContextCompat.getColor(context, background)); + final Drawable icon = setTintList(context, iconRes, iconTint); + return createLayerIcon(drawable, icon, inset); + } + + @NonNull public static Drawable createLayerIcon(@Nullable Drawable background, @Nullable Drawable icon, int inset) { Drawable[] layer = {background, icon}; @@ -103,6 +121,14 @@ public static Drawable createLayerIcon(@Nullable Drawable background, @Nullable return layerDrawable; } + @NonNull + public static Drawable createRoundedShapeDrawable(@ColorInt int color, float radius) { + final GradientDrawable shape = new GradientDrawable(); + shape.setCornerRadius(radius); + shape.setColor(color); + return shape; + } + @Nullable public static Bitmap toBitmap(@NonNull Drawable drawable) { if (drawable instanceof BitmapDrawable) { diff --git a/uikit/src/main/java/com/sendbird/uikit/utils/MetricsUtils.java b/uikit/src/main/java/com/sendbird/uikit/utils/MetricsUtils.java index ba9c4848..4c82d723 100644 --- a/uikit/src/main/java/com/sendbird/uikit/utils/MetricsUtils.java +++ b/uikit/src/main/java/com/sendbird/uikit/utils/MetricsUtils.java @@ -83,4 +83,13 @@ public static Pair getScreenSize(@NonNull Context context) { display.getSize(point); return new Pair<>(point.x, point.y); } + + @NonNull + public static int getDeviceWidth(@NonNull Context context) { + WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); + Display display = wm.getDefaultDisplay(); + Point point = new Point(); + display.getSize(point); + return Math.min(point.x, point.y); + } } diff --git a/uikit/src/main/java/com/sendbird/uikit/utils/ReactionUtils.java b/uikit/src/main/java/com/sendbird/uikit/utils/ReactionUtils.java index be3cb00a..ef62c2b2 100644 --- a/uikit/src/main/java/com/sendbird/uikit/utils/ReactionUtils.java +++ b/uikit/src/main/java/com/sendbird/uikit/utils/ReactionUtils.java @@ -10,7 +10,7 @@ public class ReactionUtils { public static boolean useReaction(@Nullable BaseChannel channel) { if (channel instanceof GroupChannel) { GroupChannel groupChannel = (GroupChannel) channel; - if (groupChannel.isSuper() || groupChannel.isBroadcast()) { + if (groupChannel.isSuper() || groupChannel.isBroadcast() || groupChannel.isChatNotification()) { return false; } else { return Available.isSupportReaction(); diff --git a/uikit/src/main/java/com/sendbird/uikit/utils/UIKitPrefs.java b/uikit/src/main/java/com/sendbird/uikit/utils/UIKitPrefs.java index 991434d8..8848de01 100644 --- a/uikit/src/main/java/com/sendbird/uikit/utils/UIKitPrefs.java +++ b/uikit/src/main/java/com/sendbird/uikit/utils/UIKitPrefs.java @@ -6,6 +6,10 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import com.sendbird.uikit.log.Logger; + +import java.util.concurrent.Executors; + @SuppressWarnings("unused") final public class UIKitPrefs { @NonNull @@ -16,10 +20,15 @@ final public class UIKitPrefs { private UIKitPrefs() {} public static void init(@NonNull Context context) { - preferences = context.getApplicationContext().getSharedPreferences( - PREFERENCE_FILE_NAME, - Context.MODE_PRIVATE - ); + try { + // execute IO operations on the executor to avoid strict mode logs + preferences = Executors.newSingleThreadExecutor().submit(() -> context.getApplicationContext().getSharedPreferences( + PREFERENCE_FILE_NAME, + Context.MODE_PRIVATE + )).get(); + } catch (Throwable e) { + Logger.w(e); + } } public static void clearAll() { diff --git a/uikit/src/main/java/com/sendbird/uikit/utils/ViewUtils.java b/uikit/src/main/java/com/sendbird/uikit/utils/ViewUtils.java index cf54aaa6..3d810354 100644 --- a/uikit/src/main/java/com/sendbird/uikit/utils/ViewUtils.java +++ b/uikit/src/main/java/com/sendbird/uikit/utils/ViewUtils.java @@ -243,6 +243,15 @@ public static void drawNickname(@NonNull TextView tvNickname, @Nullable BaseMess tvNickname.setText(nickname); } + public static void drawNotificationProfile(@NonNull ImageView ivProfile, @Nullable BaseMessage message) { + int iconTint = SendbirdUIKit.isDarkMode() ? R.color.onlight_01 : R.color.ondark_01; + int backgroundTint = R.color.background_300; + int inset = ivProfile.getContext().getResources().getDimensionPixelSize(R.dimen.sb_size_6); + final Drawable profile = DrawableUtils.createOvalIconWithInset(ivProfile.getContext(), + backgroundTint, R.drawable.icon_channels, iconTint, inset); + ivProfile.setImageDrawable(profile); + } + public static void drawProfile(@NonNull ImageView ivProfile, @Nullable BaseMessage message) { if (message == null) { return; diff --git a/uikit/src/main/java/com/sendbird/uikit/vm/ChannelListViewModel.java b/uikit/src/main/java/com/sendbird/uikit/vm/ChannelListViewModel.java index 241904cd..51f99791 100644 --- a/uikit/src/main/java/com/sendbird/uikit/vm/ChannelListViewModel.java +++ b/uikit/src/main/java/com/sendbird/uikit/vm/ChannelListViewModel.java @@ -20,6 +20,7 @@ import com.sendbird.uikit.internal.tasks.JobTask; import com.sendbird.uikit.internal.tasks.TaskQueue; import com.sendbird.uikit.log.Logger; +import com.sendbird.uikit.utils.Available; import java.util.Collections; import java.util.List; @@ -99,6 +100,7 @@ private synchronized void disposeChannelCollection() { private void notifyChannelChanged() { if (collection == null) return; List newList = collection.getChannelList(); + Logger.d(">> ChannelListViewModel::notifyDataSetChanged(), size = %s", newList.size()); channelList.postValue(newList); } @@ -248,6 +250,8 @@ public void authenticate(@NonNull AuthenticateHandler handler) { */ @NonNull protected GroupChannelListQuery createGroupChannelListQuery() { - return GroupChannel.createMyGroupChannelListQuery(new GroupChannelListQueryParams()); + final GroupChannelListQueryParams params = new GroupChannelListQueryParams(); + params.setIncludeChatNotification(Available.isSupportChatNotification()); + return GroupChannel.createMyGroupChannelListQuery(params); } } diff --git a/uikit/src/main/java/com/sendbird/uikit/vm/ChatNotificationChannelViewModel.java b/uikit/src/main/java/com/sendbird/uikit/vm/ChatNotificationChannelViewModel.java new file mode 100644 index 00000000..c1616eeb --- /dev/null +++ b/uikit/src/main/java/com/sendbird/uikit/vm/ChatNotificationChannelViewModel.java @@ -0,0 +1,453 @@ +package com.sendbird.uikit.vm; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; +import androidx.annotation.WorkerThread; +import androidx.lifecycle.Lifecycle; +import androidx.lifecycle.LifecycleEventObserver; +import androidx.lifecycle.LifecycleOwner; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; + +import com.sendbird.android.SendbirdChat; +import com.sendbird.android.channel.GroupChannel; +import com.sendbird.android.collection.GroupChannelContext; +import com.sendbird.android.collection.MessageCollection; +import com.sendbird.android.collection.MessageCollectionInitPolicy; +import com.sendbird.android.collection.MessageContext; +import com.sendbird.android.collection.Traceable; +import com.sendbird.android.exception.SendbirdException; +import com.sendbird.android.handler.MessageCollectionHandler; +import com.sendbird.android.handler.MessageCollectionInitHandler; +import com.sendbird.android.message.BaseMessage; +import com.sendbird.android.params.MessageCollectionCreateParams; +import com.sendbird.android.params.MessageListParams; +import com.sendbird.uikit.consts.StringSet; +import com.sendbird.uikit.interfaces.AuthenticateHandler; +import com.sendbird.uikit.interfaces.OnCompleteHandler; +import com.sendbird.uikit.interfaces.OnPagedDataLoader; +import com.sendbird.uikit.log.Logger; +import com.sendbird.uikit.model.LiveDataEx; +import com.sendbird.uikit.model.MessageData; +import com.sendbird.uikit.model.MessageList; +import com.sendbird.uikit.model.MutableLiveDataEx; +import com.sendbird.uikit.widgets.StatusFrameView; + +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicReference; + +/** + * ViewModel preparing and managing data related with the notification channel. + * + * @since 3.5.0 + */ +public class ChatNotificationChannelViewModel extends BaseViewModel implements OnPagedDataLoader>, LifecycleEventObserver { + @NonNull + private final MutableLiveData channelUpdated = new MutableLiveData<>(); + @NonNull + private final MutableLiveData channelDeleted = new MutableLiveData<>(); + @NonNull + private final MutableLiveData> messagesDeleted = new MutableLiveData<>(); + @NonNull + private final MutableLiveData statusFrame = new MutableLiveData<>(); + @NonNull + private final MutableLiveDataEx notificationList = new MutableLiveDataEx<>(); + @Nullable + private MessageListParams messageListParams; + @Nullable + private MessageCollection collection; + @NonNull + private final String channelUrl; + @Nullable + private GroupChannel channel; + + private boolean isVisible = false; + + /** + * Constructor + * + * @param channelUrl The URL of a channel this view model is currently associated with + * @param messageListParams Parameters required to retrieve the message list from this view model + * @since 3.5.0 + */ + public ChatNotificationChannelViewModel(@NonNull String channelUrl, @Nullable MessageListParams messageListParams) { + this.channelUrl = channelUrl; + this.messageListParams = messageListParams; + } + + /** + * Tries to connect Sendbird Server and retrieve a channel instance. + * + * @param handler Callback notifying the result of authentication + * @since 3.5.0 + */ + @Override + public void authenticate(@NonNull AuthenticateHandler handler) { + connect((user, e) -> { + if (user != null) { + GroupChannel.getChannel(channelUrl, (channel, e1) -> { + ChatNotificationChannelViewModel.this.channel = channel; + if (e1 != null) { + handler.onAuthenticationFailed(); + } else { + handler.onAuthenticated(); + } + }); + } else { + handler.onAuthenticationFailed(); + } + }); + } + + @Override + protected void onCleared() { + super.onCleared(); + Logger.d("-- onCleared ChatNotificationChannelViewModel"); + disposeMessageCollection(); + } + + /** + * Returns {@code GroupChannel}. If the authentication failed, {@code null} is returned. + * + * @return {@code GroupChannel} this view model is currently associated with + * @since 3.5.0 + */ + @Nullable + public GroupChannel getChannel() { + return channel; + } + + /** + * Returns parameters required to retrieve the message list from this view model + * + * @return {@link MessageListParams} used in this view model + * @since 3.5.0 + */ + @Nullable + public MessageListParams getMessageListParams() { + return messageListParams; + } + + /** + * Returns URL of GroupChannel. + * + * @return The URL of a channel this view model is currently associated with + * @since 3.5.0 + */ + @NonNull + public String getChannelUrl() { + return channelUrl; + } + + /** + * Returns LiveData that can be observed for the list of messages. + * + * @return LiveData holding the latest {@link ChannelViewModel.ChannelMessageData} + * @since 3.5.0 + */ + @NonNull + public LiveDataEx getNotificationList() { + return notificationList; + } + + /** + * Returns LiveData that can be observed if the channel has been updated. + * + * @return LiveData holding the updated {@code GroupChannel} + * @since 3.5.0 + */ + @NonNull + public LiveData onChannelUpdated() { + return channelUpdated; + } + + /** + * Returns LiveData that can be observed for the status of the result of fetching the message list. + * When the message list is fetched successfully, the status is {@link StatusFrameView.Status#NONE}. + * + * @return The Status for the message list + * @since 3.5.0 + */ + @NonNull + public MutableLiveData getStatusFrame() { + return statusFrame; + } + + /** + * Returns LiveData that can be observed if the channel has been deleted. + * + * @return LiveData holding the URL of the deleted {@code GroupChannel} + * @since 3.5.0 + */ + @NonNull + public LiveData onChannelDeleted() { + return channelDeleted; + } + + /** + * Requests the list of BaseMessages for the first time. + * If there is no more pages to be read, an empty List (not null) returns. + * If the request is succeed, you can observe updated data through {@link #getNotificationList()}. + * + * @param startingPoint Timestamp that is the starting point when the message list is fetched + * @since 3.5.0 + */ + @UiThread + public synchronized boolean loadInitial(final long startingPoint) { + Logger.d(">> ChatNotificationChannelViewModel::loadInitial() startingPoint=%s", startingPoint); + initMessageCollection(startingPoint); + if (collection == null) { + Logger.d("-- channel instance is null. an authenticate process must be proceed first"); + return false; + } + + collection.initialize(MessageCollectionInitPolicy.CACHE_AND_REPLACE_BY_API, new MessageCollectionInitHandler() { + @Override + public void onCacheResult(@Nullable List cachedList, @Nullable SendbirdException e) { + if (e == null && cachedList != null && cachedList.size() > 0) { + notifyDataSetChanged(StringSet.ACTION_INIT_FROM_CACHE); + } + } + + @Override + public void onApiResult(@Nullable List apiResultList, @Nullable SendbirdException e) { + if (e == null && apiResultList != null) { + notifyDataSetChanged(StringSet.ACTION_INIT_FROM_REMOTE); + if (apiResultList.size() > 0) { + markAsRead(); + } + } + } + }); + return true; + } + + /** + * Requests the list of BaseMessages when the page goes to the previous. + * If there is no more pages to be read, an empty List (not null) returns. + * If the request is succeed, you can observe updated data through {@link #getNotificationList()}. + * + * @return Returns the list of BaseMessages if no error occurs + * @throws Exception Throws exception if getting the message list are failed + * @since 3.5.0 + */ + @WorkerThread + @NonNull + @Override + public List loadPrevious() throws Exception { + if (!hasPrevious() || collection == null) return Collections.emptyList(); + Logger.i(">> ChatNotificationChannelViewModel::loadPrevious()"); + + final AtomicReference> result = new AtomicReference<>(); + final AtomicReference error = new AtomicReference<>(); + final CountDownLatch lock = new CountDownLatch(1); + + collection.loadPrevious((messages, e) -> { + try { + if (e == null) { + messages = messages == null ? Collections.emptyList() : messages; + result.set(messages); + notifyDataSetChanged(StringSet.ACTION_PREVIOUS); + } + error.set(e); + } finally { + lock.countDown(); + } + }); + lock.await(); + + if (error.get() != null) throw error.get(); + return result.get(); + } + + /** + * Requests the list of BaseMessages when the page goes to the next. + * If there is no more pages to be read, an empty List (not null) returns. + * If the request is succeed, you can observe updated data through {@link #getNotificationList()}. + * + * @return Returns the list of BaseMessages if no error occurs + * @throws Exception Throws exception if getting the message list are failed + * @since 3.5.0 + */ + @WorkerThread + @NonNull + @Override + public List loadNext() throws Exception { + return Collections.emptyList(); + } + + @Override + public boolean hasNext() { + return false; + } + + @Override + public boolean hasPrevious() { + return collection == null || collection.getHasPrevious(); + } + + @UiThread + synchronized void notifyDataSetChanged(@NonNull Traceable trace) { + notifyDataSetChanged(trace.getTraceName()); + } + + @UiThread + private synchronized void notifyChannelDataChanged() { + Logger.d(">> ChatNotificationChannelViewModel::notifyChannelDataChanged()"); + channelUpdated.setValue(channel); + } + + @UiThread + private synchronized void notifyDataSetChanged(@NonNull String traceName) { + Logger.d(">> ChatNotificationChannelViewModel::notifyDataSetChanged()"); + if (collection == null) return; + final List copiedList = collection.getSucceededMessages(); + if (copiedList.size() == 0) { + statusFrame.setValue(StatusFrameView.Status.EMPTY); + } else { + statusFrame.setValue(StatusFrameView.Status.NONE); + final MessageList messages = new MessageList(); + messages.addAll(copiedList); + notificationList.setValue(new MessageData(traceName, messages.toList())); + } + } + + @UiThread + private synchronized void notifyMessagesDeleted(@NonNull List deletedMessages) { + messagesDeleted.setValue(deletedMessages); + } + + @UiThread + private synchronized void notifyChannelDeleted(@NonNull String channelUrl) { + channelDeleted.setValue(channelUrl); + } + + private synchronized void initMessageCollection(final long startingPoint) { + Logger.i(">> ChatNotificationChannelViewModel::initMessageCollection()"); + final GroupChannel channel = getChannel(); + if (channel == null) return; + if (this.collection != null) { + disposeMessageCollection(); + } + if (this.messageListParams == null) { + this.messageListParams = createMessageListParams(); + } + this.messageListParams.setReverse(true); + this.collection = SendbirdChat.createMessageCollection(new MessageCollectionCreateParams(channel, this.messageListParams, startingPoint, new MessageCollectionHandler() { + @UiThread + @Override + public void onMessagesAdded(@NonNull MessageContext context, @NonNull GroupChannel channel, @NonNull List messages) { + Logger.d(">> ChatNotificationChannelViewModel::onMessagesAdded() from=%s", context.getCollectionEventSource()); + if (messages.isEmpty()) return; + + switch (context.getCollectionEventSource()) { + case EVENT_MESSAGE_RECEIVED: + case EVENT_MESSAGE_SENT: + case MESSAGE_FILL: + if (isVisible) markAsRead(); + break; + } + notifyDataSetChanged(context); + } + + @UiThread + @Override + public void onMessagesUpdated(@NonNull MessageContext context, @NonNull GroupChannel channel, @NonNull List messages) { + Logger.d(">> ChatNotificationChannelViewModel::onMessagesUpdated() from=%s", context.getCollectionEventSource()); + notifyDataSetChanged(context); + } + + @UiThread + @Override + public void onMessagesDeleted(@NonNull MessageContext context, @NonNull GroupChannel channel, @NonNull List messages) { + Logger.d(">> ChatNotificationChannelViewModel::onMessagesDeleted() from=%s", context.getCollectionEventSource()); + // Remove the succeeded message from the succeeded message datasource. + notifyMessagesDeleted(messages); + notifyDataSetChanged(context); + } + + @UiThread + @Override + public void onChannelDeleted(@NonNull GroupChannelContext context, @NonNull String channelUrl) { + Logger.d(">> ChatNotificationChannelViewModel::onChannelDeleted() from=%s", context.getCollectionEventSource()); + notifyChannelDeleted(channelUrl); + } + + @UiThread + @Override + public void onHugeGapDetected() { + Logger.d(">> ChatNotificationChannelViewModel::onHugeGapDetected()"); + } + + @Override + public void onChannelUpdated(@NonNull GroupChannelContext context, @NonNull GroupChannel channel) { + Logger.d(">> ChatNotificationChannelViewModel::onChannelUpdated() from=%s, url=%s", context.getCollectionEventSource(), channel.getUrl()); + notifyChannelDataChanged(); + } + })); + } + + private synchronized void disposeMessageCollection() { + Logger.i(">> ChatNotificationChannelViewModel::disposeMessageCollection()"); + if (this.collection != null) { + this.collection.setMessageCollectionHandler(null); + this.collection.dispose(); + } + } + + public void markAsRead() { + Logger.d(">> ChatNotificationChannelViewModel::markAsRead()"); + if (channel != null) channel.markAsRead(null); + } + + /** + * Deletes a message. + * + * @param message Message to be deleted + * @param handler Callback handler called when this method is completed + * @since 3.5.0 + */ + public void deleteMessage(@NonNull BaseMessage message, @Nullable OnCompleteHandler handler) { + if (channel == null) return; + channel.deleteMessage(message, e -> { + if (handler != null) handler.onComplete(e); + Logger.i("++ deleted message : %s", message); + }); + } + + /** + * Creates params for the message list when loading the message list. + * + * @return {@link MessageListParams} to be used when loading the message list + * @since 3.5.0 + */ + @NonNull + public MessageListParams createMessageListParams() { + final MessageListParams params = new MessageListParams(); + params.setReverse(true); + return params; + } + + /** + * Called when a state transition event happens. + * + * @param source The source of the event + * @param event The event + */ + @Override + public void onStateChanged(@NonNull LifecycleOwner source, @NonNull Lifecycle.Event event) { + Logger.i(">> ChatNotificationChannelViewModel::onStateChanged(%s)", event); + switch (event) { + case ON_RESUME: + isVisible = true; + markAsRead(); + break; + case ON_PAUSE: + isVisible = false; + break; + } + } +} diff --git a/uikit/src/main/java/com/sendbird/uikit/vm/FeedNotificationChannelViewModel.java b/uikit/src/main/java/com/sendbird/uikit/vm/FeedNotificationChannelViewModel.java new file mode 100644 index 00000000..ae72b03d --- /dev/null +++ b/uikit/src/main/java/com/sendbird/uikit/vm/FeedNotificationChannelViewModel.java @@ -0,0 +1,429 @@ +package com.sendbird.uikit.vm; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; +import androidx.annotation.WorkerThread; +import androidx.lifecycle.Lifecycle; +import androidx.lifecycle.LifecycleEventObserver; +import androidx.lifecycle.LifecycleOwner; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; + +import com.sendbird.android.channel.FeedChannel; +import com.sendbird.android.collection.FeedChannelContext; +import com.sendbird.android.collection.MessageCollectionInitPolicy; +import com.sendbird.android.collection.NotificationCollection; +import com.sendbird.android.collection.NotificationContext; +import com.sendbird.android.collection.Traceable; +import com.sendbird.android.exception.SendbirdException; +import com.sendbird.android.handler.MessageCollectionInitHandler; +import com.sendbird.android.handler.NotificationCollectionHandler; +import com.sendbird.android.message.BaseMessage; +import com.sendbird.android.params.MessageListParams; +import com.sendbird.uikit.consts.StringSet; +import com.sendbird.uikit.interfaces.AuthenticateHandler; +import com.sendbird.uikit.interfaces.OnPagedDataLoader; +import com.sendbird.uikit.log.Logger; +import com.sendbird.uikit.model.LiveDataEx; +import com.sendbird.uikit.model.MessageData; +import com.sendbird.uikit.model.MutableLiveDataEx; +import com.sendbird.uikit.widgets.StatusFrameView; + +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicReference; + +/** + * ViewModel preparing and managing data related with the notification channel. + * + * @since 3.5.0 + */ +public class FeedNotificationChannelViewModel extends BaseViewModel implements OnPagedDataLoader>, LifecycleEventObserver { + @NonNull + private final MutableLiveData channelUpdated = new MutableLiveData<>(); + @NonNull + private final MutableLiveData channelDeleted = new MutableLiveData<>(); + @NonNull + private final MutableLiveData> messagesDeleted = new MutableLiveData<>(); + @NonNull + private final MutableLiveData statusFrame = new MutableLiveData<>(); + @NonNull + private final MutableLiveDataEx messageList = new MutableLiveDataEx<>(); + @Nullable + private MessageListParams messageListParams; + @Nullable + private NotificationCollection collection; + @NonNull + private final String channelUrl; + @Nullable + private FeedChannel channel; + private boolean isVisible = false; + + /** + * Constructor + * + * @param channelUrl The URL of a channel this view model is currently associated with + * @param messageListParams Parameters required to retrieve the message list from this view model + * @since 3.5.0 + */ + public FeedNotificationChannelViewModel(@NonNull String channelUrl, @Nullable MessageListParams messageListParams) { + this.channelUrl = channelUrl; + this.messageListParams = messageListParams; + } + + /** + * Tries to connect Sendbird Server and retrieve a channel instance. + * + * @param handler Callback notifying the result of authentication + * @since 3.5.0 + */ + @Override + public void authenticate(@NonNull AuthenticateHandler handler) { + connect((user, e) -> { + if (user != null) { + FeedChannel.getChannel(channelUrl, (channel, e1) -> { + FeedNotificationChannelViewModel.this.channel = channel; + if (e1 != null) { + handler.onAuthenticationFailed(); + } else { + handler.onAuthenticated(); + } + }); + } else { + handler.onAuthenticationFailed(); + } + }); + } + + @Override + protected void onCleared() { + super.onCleared(); + Logger.d("-- onCleared FeedNotificationChannelViewModel"); + disposeNotificationCollection(); + } + + /** + * Returns {@code FeedChannel}. If the authentication failed, {@code null} is returned. + * + * @return {@code FeedChannel} this view model is currently associated with + * @since 3.5.0 + */ + @Nullable + public FeedChannel getChannel() { + return channel; + } + + /** + * Returns parameters required to retrieve the message list from this view model + * + * @return {@link MessageListParams} used in this view model + * @since 3.5.0 + */ + @Nullable + public MessageListParams getMessageListParams() { + return messageListParams; + } + + /** + * Returns URL of FeedChannel. + * + * @return The URL of a channel this view model is currently associated with + * @since 3.5.0 + */ + @NonNull + public String getChannelUrl() { + return channelUrl; + } + + /** + * Returns LiveData that can be observed for the list of messages. + * + * @return LiveData holding the latest {@link ChannelViewModel.ChannelMessageData} + * @since 3.5.0 + */ + @NonNull + public LiveDataEx getMessageList() { + return messageList; + } + + /** + * Returns LiveData that can be observed if the channel has been updated. + * + * @return LiveData holding the updated {@code FeedChannel} + * @since 3.5.0 + */ + @NonNull + public LiveData onChannelUpdated() { + return channelUpdated; + } + + /** + * Returns LiveData that can be observed for the status of the result of fetching the message list. + * When the message list is fetched successfully, the status is {@link StatusFrameView.Status#NONE}. + * + * @return The Status for the message list + * @since 3.5.0 + */ + @NonNull + public MutableLiveData getStatusFrame() { + return statusFrame; + } + + /** + * Returns LiveData that can be observed if the channel has been deleted. + * + * @return LiveData holding the URL of the deleted {@code FeedChannel} + * @since 3.5.0 + */ + @NonNull + public LiveData onChannelDeleted() { + return channelDeleted; + } + + /** + * Requests the list of BaseMessages for the first time. + * If there is no more pages to be read, an empty List (not null) returns. + * If the request is succeed, you can observe updated data through {@link #getMessageList()}. + * + * @param startingPoint Timestamp that is the starting point when the message list is fetched + * @since 3.5.0 + */ + @UiThread + public synchronized boolean loadInitial(final long startingPoint) { + Logger.d(">> FeedNotificationChannelViewModel::loadInitial() startingPoint=%s", startingPoint); + initNotificationCollection(startingPoint); + if (collection == null) { + Logger.d("-- channel instance is null. an authenticate process must be proceed first"); + return false; + } + + collection.initialize(MessageCollectionInitPolicy.CACHE_AND_REPLACE_BY_API, new MessageCollectionInitHandler() { + @Override + public void onCacheResult(@Nullable List cachedList, @Nullable SendbirdException e) { + if (e == null && cachedList != null && cachedList.size() > 0) { + notifyDataSetChanged(StringSet.ACTION_INIT_FROM_CACHE); + } + } + + @Override + public void onApiResult(@Nullable List apiResultList, @Nullable SendbirdException e) { + if (e == null && apiResultList != null) { + notifyDataSetChanged(StringSet.ACTION_INIT_FROM_REMOTE); + if (apiResultList.size() > 0) { + if (isVisible) markAsRead(); + } + } + } + }); + return true; + } + + /** + * Requests the list of BaseMessages when the page goes to the previous. + * If there is no more pages to be read, an empty List (not null) returns. + * If the request is succeed, you can observe updated data through {@link #getMessageList()}. + * + * @return Returns the list of BaseMessages if no error occurs + * @throws Exception Throws exception if getting the message list are failed + * @since 3.5.0 + */ + @WorkerThread + @NonNull + @Override + public List loadPrevious() throws Exception { + if (!hasPrevious() || collection == null) return Collections.emptyList(); + Logger.i(">> FeedNotificationChannelViewModel::loadPrevious()"); + + final AtomicReference> result = new AtomicReference<>(); + final AtomicReference error = new AtomicReference<>(); + final CountDownLatch lock = new CountDownLatch(1); + + collection.loadPrevious((messages, e) -> { + try { + if (e == null) { + messages = messages == null ? Collections.emptyList() : messages; + result.set(messages); + notifyDataSetChanged(StringSet.ACTION_PREVIOUS); + } + error.set(e); + } finally { + lock.countDown(); + } + }); + lock.await(); + + if (error.get() != null) throw error.get(); + return result.get(); + } + + /** + * Requests the list of BaseMessages when the page goes to the next. + * If there is no more pages to be read, an empty List (not null) returns. + * If the request is succeed, you can observe updated data through {@link #getMessageList()}. + * + * @return Returns the list of BaseMessages if no error occurs + * @throws Exception Throws exception if getting the message list are failed + * @since 3.5.0 + */ + @WorkerThread + @NonNull + @Override + public List loadNext() throws Exception { + return Collections.emptyList(); + } + + @Override + public boolean hasNext() { + return false; + } + + @Override + public boolean hasPrevious() { + return collection == null || collection.getHasPrevious(); + } + + @UiThread + synchronized void notifyDataSetChanged(@NonNull Traceable trace) { + notifyDataSetChanged(trace.getTraceName()); + } + + @UiThread + private synchronized void notifyChannelDataChanged() { + Logger.d(">> FeedNotificationChannelViewModel::notifyChannelDataChanged()"); + channelUpdated.setValue(channel); + } + + @UiThread + private synchronized void notifyDataSetChanged(@NonNull String traceName) { + Logger.d(">> FeedNotificationChannelViewModel::notifyDataSetChanged()"); + if (collection == null) return; + final List copiedList = collection.getSucceededMessages(); + if (copiedList.size() == 0) { + statusFrame.setValue(StatusFrameView.Status.EMPTY); + } else { + statusFrame.setValue(StatusFrameView.Status.NONE); + messageList.setValue(new MessageData(traceName, copiedList)); + } + } + + @UiThread + private synchronized void notifyMessagesDeleted(@NonNull List deletedMessages) { + messagesDeleted.setValue(deletedMessages); + } + + @UiThread + private synchronized void notifyChannelDeleted(@NonNull String channelUrl) { + channelDeleted.setValue(channelUrl); + } + + private synchronized void initNotificationCollection(final long startingPoint) { + Logger.i(">> FeedNotificationChannelViewModel::initMessageCollection()"); + final FeedChannel channel = getChannel(); + if (channel == null) return; + if (this.collection != null) { + disposeNotificationCollection(); + } + if (this.messageListParams == null) { + this.messageListParams = createMessageListParams(); + } + this.messageListParams.setReverse(true); + this.collection = channel.createNotificationCollection(this.messageListParams, startingPoint, new NotificationCollectionHandler() { + @UiThread + @Override + public void onMessagesAdded(@NonNull NotificationContext context, @NonNull FeedChannel channel, @NonNull List messages) { + Logger.d(">> FeedNotificationChannelViewModel::onMessagesAdded() from=%s", context.getCollectionEventSource()); + if (messages.isEmpty()) return; + + switch (context.getCollectionEventSource()) { + case EVENT_MESSAGE_RECEIVED: + case EVENT_MESSAGE_SENT: + case MESSAGE_FILL: + if (isVisible) markAsRead(); + break; + } + notifyDataSetChanged(context); + } + + @UiThread + @Override + public void onMessagesUpdated(@NonNull NotificationContext context, @NonNull FeedChannel channel, @NonNull List messages) { + Logger.d(">> FeedNotificationChannelViewModel::onMessagesUpdated() from=%s", context.getCollectionEventSource()); + notifyDataSetChanged(context); + } + + @UiThread + @Override + public void onMessagesDeleted(@NonNull NotificationContext context, @NonNull FeedChannel channel, @NonNull List messages) { + Logger.d(">> FeedNotificationChannelViewModel::onMessagesDeleted() from=%s", context.getCollectionEventSource()); + // Remove the succeeded message from the succeeded message datasource. + notifyMessagesDeleted(messages); + notifyDataSetChanged(context); + } + + @UiThread + @Override + public void onChannelDeleted(@NonNull FeedChannelContext context, @NonNull String channelUrl) { + Logger.d(">> FeedNotificationChannelViewModel::onChannelDeleted() from=%s", context.getCollectionEventSource()); + notifyChannelDeleted(channelUrl); + } + + @UiThread + @Override + public void onHugeGapDetected() { + Logger.d(">> FeedNotificationChannelViewModel::onHugeGapDetected()"); + } + + @Override + public void onChannelUpdated(@NonNull FeedChannelContext context, @NonNull FeedChannel channel) { + Logger.d(">> FeedNotificationChannelViewModel::onChannelUpdated() from=%s, url=%s", context.getCollectionEventSource(), channel.getUrl()); + notifyChannelDataChanged(); + } + }); + } + + private synchronized void disposeNotificationCollection() { + Logger.i(">> FeedNotificationChannelViewModel::disposeNotificationCollection()"); + if (this.collection != null) { + this.collection.setNotificationCollectionHandler(null); + this.collection.dispose(); + } + } + + public void markAsRead() { + Logger.d(">> FeedNotificationChannelViewModel::markAsRead()"); + if (channel != null) channel.markAsRead(null); + } + + /** + * Creates params for the message list when loading the message list. + * + * @return {@link MessageListParams} to be used when loading the message list + * @since 3.5.0 + */ + @NonNull + public MessageListParams createMessageListParams() { + return new MessageListParams(); + } + + /** + * Called when a state transition event happens. + * + * @param source The source of the event + * @param event The event + */ + @Override + public void onStateChanged(@NonNull LifecycleOwner source, @NonNull Lifecycle.Event event) { + Logger.i(">> FeedNotificationChannelViewModel::onStateChanged(%s)", event); + switch (event) { + case ON_RESUME: + isVisible = true; + markAsRead(); + break; + case ON_PAUSE: + isVisible = false; + break; + } + } +} diff --git a/uikit/src/main/java/com/sendbird/uikit/vm/NotificationViewModelFactory.java b/uikit/src/main/java/com/sendbird/uikit/vm/NotificationViewModelFactory.java new file mode 100644 index 00000000..91959d28 --- /dev/null +++ b/uikit/src/main/java/com/sendbird/uikit/vm/NotificationViewModelFactory.java @@ -0,0 +1,38 @@ +package com.sendbird.uikit.vm; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import com.sendbird.android.params.MessageListParams; + +public class NotificationViewModelFactory extends ViewModelProvider.NewInstanceFactory { + @NonNull + private final String channelUrl; + + @Nullable + private MessageListParams params = null; + + public NotificationViewModelFactory(@NonNull String channelUrl) { + this(channelUrl, null); + } + + public NotificationViewModelFactory(@NonNull String channelUrl, @Nullable MessageListParams params) { + this.channelUrl = channelUrl; + this.params = params; + } + + @SuppressWarnings("unchecked") + @NonNull + @Override + public T create(@NonNull Class modelClass) { + if (modelClass.isAssignableFrom(FeedNotificationChannelViewModel.class)) { + return (T) new FeedNotificationChannelViewModel(channelUrl, params); + } else if (modelClass.isAssignableFrom(ChatNotificationChannelViewModel.class)) { + return (T) new ChatNotificationChannelViewModel(channelUrl, params); + } else { + return super.create(modelClass); + } + } +} diff --git a/uikit/src/main/res/drawable/sb_shape_round_rect_background_200.xml b/uikit/src/main/res/drawable/sb_shape_round_rect_background_200.xml new file mode 100644 index 00000000..0649f9a6 --- /dev/null +++ b/uikit/src/main/res/drawable/sb_shape_round_rect_background_200.xml @@ -0,0 +1,9 @@ + + + + + + + diff --git a/uikit/src/main/res/drawable/sb_shape_round_rect_background_400.xml b/uikit/src/main/res/drawable/sb_shape_round_rect_background_400.xml new file mode 100644 index 00000000..188ced2a --- /dev/null +++ b/uikit/src/main/res/drawable/sb_shape_round_rect_background_400.xml @@ -0,0 +1,9 @@ + + + + + + + diff --git a/uikit/src/main/res/drawable/sb_shape_timeline_background.xml b/uikit/src/main/res/drawable/sb_shape_timeline_background.xml index 93828092..abcb34b7 100644 --- a/uikit/src/main/res/drawable/sb_shape_timeline_background.xml +++ b/uikit/src/main/res/drawable/sb_shape_timeline_background.xml @@ -8,10 +8,4 @@ - - - \ No newline at end of file + diff --git a/uikit/src/main/res/layout/sb_view_chat_notification.xml b/uikit/src/main/res/layout/sb_view_chat_notification.xml new file mode 100644 index 00000000..aad38f62 --- /dev/null +++ b/uikit/src/main/res/layout/sb_view_chat_notification.xml @@ -0,0 +1,6 @@ + + diff --git a/uikit/src/main/res/layout/sb_view_chat_notification_component.xml b/uikit/src/main/res/layout/sb_view_chat_notification_component.xml new file mode 100644 index 00000000..888c1166 --- /dev/null +++ b/uikit/src/main/res/layout/sb_view_chat_notification_component.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + diff --git a/uikit/src/main/res/layout/sb_view_chat_notification_recycler_view.xml b/uikit/src/main/res/layout/sb_view_chat_notification_recycler_view.xml new file mode 100644 index 00000000..5681ac81 --- /dev/null +++ b/uikit/src/main/res/layout/sb_view_chat_notification_recycler_view.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + diff --git a/uikit/src/main/res/layout/sb_view_feed_notification.xml b/uikit/src/main/res/layout/sb_view_feed_notification.xml new file mode 100644 index 00000000..18f4942d --- /dev/null +++ b/uikit/src/main/res/layout/sb_view_feed_notification.xml @@ -0,0 +1,6 @@ + + diff --git a/uikit/src/main/res/layout/sb_view_feed_notification_component.xml b/uikit/src/main/res/layout/sb_view_feed_notification_component.xml new file mode 100644 index 00000000..8a70a444 --- /dev/null +++ b/uikit/src/main/res/layout/sb_view_feed_notification_component.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + diff --git a/uikit/src/main/res/layout/sb_view_open_channel_settings_info.xml b/uikit/src/main/res/layout/sb_view_open_channel_settings_info.xml index 2341b019..4a6ac0d5 100644 --- a/uikit/src/main/res/layout/sb_view_open_channel_settings_info.xml +++ b/uikit/src/main/res/layout/sb_view_open_channel_settings_info.xml @@ -4,7 +4,7 @@ android:layout_height="wrap_content" android:orientation="vertical" xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto">> + xmlns:app="http://schemas.android.com/apk/res-auto"> - \ No newline at end of file + diff --git a/uikit/src/main/res/layout/sb_view_time_line_message_component.xml b/uikit/src/main/res/layout/sb_view_time_line_message_component.xml index da436770..9316cfeb 100644 --- a/uikit/src/main/res/layout/sb_view_time_line_message_component.xml +++ b/uikit/src/main/res/layout/sb_view_time_line_message_component.xml @@ -9,10 +9,12 @@ android:id="@+id/tvTimeline" android:layout_gravity="center" android:layout_width="wrap_content" - android:layout_height="wrap_content" + android:layout_height="@dimen/sb_size_20" android:layout_marginTop="@dimen/sb_size_8" android:layout_marginBottom="@dimen/sb_size_8" android:gravity="center" + android:paddingLeft="@dimen/sb_size_10" + android:paddingRight="@dimen/sb_size_10" android:minWidth="@dimen/sb_size_50" app:layout_constraintTop_toTopOf="parent" app:layout_constraintStart_toStartOf="parent" @@ -20,4 +22,4 @@ app:layout_constraintBottom_toBottomOf="parent" /> - \ No newline at end of file + diff --git a/uikit/src/main/res/values/attrs.xml b/uikit/src/main/res/values/attrs.xml index 95f2441d..1312e749 100644 --- a/uikit/src/main/res/values/attrs.xml +++ b/uikit/src/main/res/values/attrs.xml @@ -26,6 +26,8 @@ + + @@ -114,6 +116,8 @@ + + @@ -302,6 +306,21 @@ + + + + + + + + + + + + + + + @@ -363,6 +382,12 @@ + + + + + + diff --git a/uikit/src/main/res/values/strings.xml b/uikit/src/main/res/values/strings.xml index 0ebda94e..6d946252 100644 --- a/uikit/src/main/res/values/strings.xml +++ b/uikit/src/main/res/values/strings.xml @@ -47,6 +47,10 @@ 99 No messages Message unavailable + (Template error) + Can\'t read this notification. + No notifications + Something went wrong. Moderations diff --git a/uikit/src/main/res/values/styles.xml b/uikit/src/main/res/values/styles.xml index 4a17f07c..241b5f9f 100755 --- a/uikit/src/main/res/values/styles.xml +++ b/uikit/src/main/res/values/styles.xml @@ -31,6 +31,8 @@ @style/Module.CreateOpenChannel @style/Module.OpenChannelList @style/Module.MessageThread + @style/Module.FeedNotificationChannel + @style/Module.ChatNotificationChannel @style/Component.ChannelMessageInput @style/Component.ChannelSettingsInfo @@ -169,6 +171,16 @@ @style/Component.MessageThreadInput @style/Component.Status.MessageThread + + + + + + + + + + + + + + + + + + + + + +