diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a083df8..9794b22 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -3,7 +3,7 @@ name: Build Release on: push: tags: - - '*' + - "*" workflow_dispatch: jobs: @@ -59,6 +59,7 @@ jobs: sudo apt-get install -y libnotify-dev sudo apt-get install -y libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev sudo apt-get install -y gstreamer-1.0 + sudo apt-get install -y libsecret-1 - run: flutter pub get - run: flutter build linux - name: Archive production artifacts @@ -80,4 +81,4 @@ jobs: uses: actions/upload-artifact@v4 with: name: build-output-linux-appimage - path: './*.AppImage*' + path: "./*.AppImage*" diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index a29f020..7ddd838 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -98,7 +98,7 @@ + android:exported="true" /> + if (call.method == "initialLink") { + val roomId = intent.getStringExtra("room_id") + if (roomId != null) { + result.success("/rooms/$roomId") + } else { + result.success(null) + } + } else { + result.notImplemented() + } + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + val roomId = intent.getStringExtra("room_id") + if (roomId != null) { + MethodChannel(flutterEngine!!.dartExecutor.binaryMessenger, CHANNEL).invokeMethod("newLink", "/rooms/$roomId") + } } } diff --git a/android/app/src/main/kotlin/dev/solsynth/solian/network/ApiClient.kt b/android/app/src/main/kotlin/dev/solsynth/solian/network/ApiClient.kt index aa8af87..ada2897 100644 --- a/android/app/src/main/kotlin/dev/solsynth/solian/network/ApiClient.kt +++ b/android/app/src/main/kotlin/dev/solsynth/solian/network/ApiClient.kt @@ -1,48 +1,47 @@ package dev.solsynth.solian.network import android.content.Context -import okhttp3.* +import android.content.SharedPreferences +import okhttp3.Call +import okhttp3.Callback import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response import org.json.JSONObject import java.io.IOException class ApiClient(private val context: Context) { private val client = OkHttpClient() + private val sharedPreferences: SharedPreferences = context.getSharedPreferences("FlutterSharedPreferences", Context.MODE_PRIVATE) - fun sendMessage(roomId: String, content: String, repliedMessageId: String, callback: () -> Unit) { - val prefs = context.getSharedPreferences("FlutterSharedPreferences", Context.MODE_PRIVATE) - val token = prefs.getString("flutter.token", null) - val serverUrl = prefs.getString("flutter.serverUrl", null) - - if (token == null || serverUrl == null) { + fun sendMessage(roomId: String, message: String, replyTo: String, callback: (Boolean) -> Unit) { + val token = sharedPreferences.getString("flutter.token", null) + if (token == null) { + callback(false) return } - val url = "$serverUrl/chat/$roomId/messages" - - val json = JSONObject() - json.put("content", content) - json.put("replied_message_id", repliedMessageId) - - val requestBody = json.toString().toRequestBody("application/json; charset=utf-8".toMediaType()) - + val json = JSONObject().apply { + put("content", message) + put("reply_to", replyTo) + } + val body = json.toString().toRequestBody("application/json; charset=utf-8".toMediaType()) val request = Request.Builder() - .url(url) - .post(requestBody) - .addHeader("Authorization", "AtField $token") + .url("https://solian.dev/api/rooms/$roomId/messages") + .header("Authorization", "Bearer $token") + .post(body) .build() client.newCall(request).enqueue(object : Callback { override fun onFailure(call: Call, e: IOException) { - // Handle failure - callback() + callback(false) } override fun onResponse(call: Call, response: Response) { - // Handle success - callback() + callback(response.isSuccessful) } }) } -} +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/dev/solsynth/solian/service/MessagingService.kt b/android/app/src/main/kotlin/dev/solsynth/solian/service/MessagingService.kt index e4a2d7d..595408d 100644 --- a/android/app/src/main/kotlin/dev/solsynth/solian/service/MessagingService.kt +++ b/android/app/src/main/kotlin/dev/solsynth/solian/service/MessagingService.kt @@ -72,6 +72,7 @@ class MessagingService: FirebaseMessagingService() { val intent = Intent(this, MainActivity::class.java) intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + intent.putExtra("room_id", roomId) val pendingIntent = PendingIntent.getActivity(this, 0, intent, pendingIntentFlags) val notificationBuilder = NotificationCompat.Builder(this, "messages") diff --git a/assets/i18n/en-US.json b/assets/i18n/en-US.json index cfd0c6d..f17ba28 100644 --- a/assets/i18n/en-US.json +++ b/assets/i18n/en-US.json @@ -89,14 +89,32 @@ "authFactorInAppNotifyDescription": "A one-time code sent via in-app notification.", "authFactorPin": "Pin Code", "authFactorPinDescription": "It consists of 6 digits. It cannot be used to log in. When performing some dangerous operations, the system will ask you to enter this PIN for confirmation.", + "realms": "Realms", + "createRealm": "Create a Realm", + "createRealmHint": "Meet friends with same interests, build communities, and more.", + "editRealm": "Edit Realm", + "deleteRealm": "Delete Realm", + "deleteRealmHint": "Are you sure to delete this realm? This will also deleted all the channels, publishers, and posts under this realm.", "explore": "Explore", "exploreFilterSubscriptions": "Subscriptions", "exploreFilterFriends": "Friends", "discover": "Discover", + "joinRealm": "Join Realm", "account": "Account", "name": "Name", "slug": "Slug", "slugHint": "The slug will be used in the URL to access this resource, it should be unique and URL safe.", + "createChatRoom": "Create a Room", + "editChatRoom": "Edit Room", + "deleteChatRoom": "Delete Room", + "deleteChatRoomHint": "Are you sure to delete this room? This action cannot be undone.", + "chat": "Chat", + "chatTabAll": "All", + "chatTabDirect": "Direct Messages", + "chatTabGroup": "Group Chats", + "chatMessageHint": "Message in {}", + "chatDirectMessageHint": "Message to {}", + "directMessage": "Direct Message", "loading": "Loading...", "descriptionNone": "No description yet.", "invites": "Invites", @@ -231,6 +249,7 @@ "uploadingProgress": "Uploading {} of {}", "uploadAll": "Upload All", "stickerCopyPlaceholder": "Copy Placeholder", + "realmSelection": "Select a Realm", "individual": "Individual", "firstPostBadgeName": "First Post", "firstPostBadgeDescription": "Created your first post on Solar Network", @@ -286,6 +305,10 @@ "levelingProgressExperience": "{} EXP", "levelingProgressLevel": "Level {}", "fileUploadingProgress": "Uploading file #{}: {}%", + "removeChatMember": "Remove Chat Room Member", + "removeChatMemberHint": "Are you sure to remove this member from the room?", + "removeRealmMember": "Remove Realm Member", + "removeRealmMemberHint": "Are you sure to remove this member from the realm?", "memberRole": "Member Role", "memberRoleHint": "Greater number has higher permission.", "memberRoleEdit": "Edit role for @{}", @@ -293,6 +316,10 @@ "openLinkConfirmDescription": "You're going to leave the Solar Network and open the link ({}) in your browser. It is not related to Solar Network. Beware of phishing and scams.", "brokenLink": "Unable open link {}... It might be broken or missing uri parts...", "copyToClipboard": "Copy to clipboard", + "leaveChatRoom": "Leave Chat Room", + "leaveChatRoomHint": "Are you sure to leave this chat room?", + "leaveRealm": "Leave Realm", + "leaveRealmHint": "Are you sure to leave this realm?", "walletNotFound": "Wallet not found", "walletCreateHint": "You don't have a wallet yet. Create one to start using the Solar Network eWallet.", "walletCreate": "Create a Wallet", @@ -304,6 +331,12 @@ "settingsBackgroundImageClear": "Clear Background Image", "settingsBackgroundGenerateColor": "Generate color scheme from Bacground Image", "messageNone": "No content to display", + "unreadMessages": { + "one": "{} unread message", + "other": "{} unread messages" + }, + "chatBreakNone": "None", + "settingsRealmCompactView": "Compact Realm View", "settingsMixedFeed": "Mixed Feed", "settingsAutoTranslate": "Auto Translate", "settingsHideBottomNav": "Hide Bottom Navigation", @@ -346,6 +379,7 @@ "postVisibilityUnlisted": "Unlisted", "postVisibilityPrivate": "Private", "postTruncated": "Content truncated, tap to view full post", + "copyMessage": "Copy Message", "authFactor": "Authentication Factor", "authFactorDelete": "Delete the Factor", "authFactorDeleteHint": "Are you sure you want to delete this authentication factor? This action cannot be undone.", @@ -373,6 +407,10 @@ "lastActiveAt": "Last active at {}", "authDeviceLogout": "Logout", "authDeviceLogoutHint": "Are you sure you want to logout this device? This will also disable the push notification to this device.", + "typingHint": { + "one": "{} is typing...", + "other": "{} are typing..." + }, "authDeviceEditLabel": "Edit Label", "authDeviceLabelTitle": "Edit Device Label", "authDeviceLabelHint": "Enter a name for this device", @@ -439,6 +477,21 @@ "contactMethodSetPrimary": "Set as Primary", "contactMethodSetPrimaryHint": "Set this contact method as your primary contact method for account recovery and notifications", "contactMethodDeleteHint": "Are you sure to delete this contact method? This action cannot be undone.", + "chatNotifyLevel": "Notify Level", + "chatNotifyLevelDescription": "Decide how many notifications you will receive.", + "chatNotifyLevelAll": "All", + "chatNotifyLevelMention": "Mentions", + "chatNotifyLevelNone": "None", + "chatNotifyLevelUpdated": "The notify level has been updated to {}.", + "chatBreak": "Take a Break", + "chatBreakDescription": "Set a time, before that time, your notification level will be metions only, to take a break of the current topic they're talking about.", + "chatBreakClear": "Clear the break time", + "chatBreakHour": "{} break", + "chatBreakDay": "{} day break", + "chatBreakSet": "Break set for {}", + "chatBreakCleared": "Chat break has been cleared.", + "chatBreakCustom": "Custom duration", + "chatBreakEnterMinutes": "Enter minutes", "firstName": "First Name", "middleName": "Middle Name", "lastName": "Last Name", @@ -520,17 +573,29 @@ "quickActions": "Quick Actions", "post": "Post", "copy": "Copy", + "sendToChat": "Send to Chat", "failedToShareToPost": "Failed to share to post: {}", "shareToChatComingSoon": "Share to chat functionality coming soon", + "failedToShareToChat": "Failed to share to chat: {}", + "shareToSpecificChatComingSoon": "Share to {} coming soon", + "directChat": "Direct Chat", "systemShareComingSoon": "System share functionality coming soon", "failedToShareToSystem": "Failed to share to system: {}", "failedToCopy": "Failed to copy: {}", + "noChatRoomsAvailable": "No chat rooms available", + "failedToLoadChats": "Failed to load chats", "contentToShare": "Content to share:", + "unknownChat": "Unknown Chat", + "addAdditionalMessage": "Add additional message...", "uploadingFiles": "Uploading files...", + "sharedSuccessfully": "Shared successfully!", "shareSuccess": "Shared successfully!", + "shareToSpecificChatSuccess": "Shared to {} successfully!", "wouldYouLikeToGoToChat": "Would you like to go to the chat?", "no": "No", "yes": "Yes", + "navigateToChat": "Navigate to Chat", + "wouldYouLikeToNavigateToChat": "Would you like to navigate to the chat?", "abuseReport": "Report", "abuseReportTitle": "Report Content", "abuseReportDescription": "Help us keep the community safe by reporting inappropriate content or behavior.", diff --git a/lib/main.dart b/lib/main.dart index 3a76cfb..3ccba49 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -7,6 +7,7 @@ import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:image_picker_android/image_picker_android.dart'; @@ -158,6 +159,28 @@ class IslandApp extends HookConsumerWidget { } useEffect(() { + const channel = MethodChannel('dev.solsynth.solian/notifications'); + + Future handleInitialLink() async { + final String? link = await channel.invokeMethod('initialLink'); + if (link != null) { + final router = ref.read(routerProvider); + router.go(link); + } + } + + if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) { + handleInitialLink(); + } + + channel.setMethodCallHandler((call) async { + if (call.method == 'newLink') { + final String link = call.arguments; + final router = ref.read(routerProvider); + router.go(link); + } + }); + // When the app is opened from a terminated state. FirebaseMessaging.instance.getInitialMessage().then((message) { if (message != null) { diff --git a/lib/route.dart b/lib/route.dart index 3144e47..c3b0234 100644 --- a/lib/route.dart +++ b/lib/route.dart @@ -79,33 +79,37 @@ final routerProvider = Provider((ref) { return EventCalanderScreen(name: name); }, ), - GoRoute( - path: '/creators', - builder: (context, state) => const CreatorHubScreen(), + ShellRoute( + builder: + (context, state, child) => CreatorHubShellScreen(child: child), routes: [ GoRoute( - path: ':name/posts', + path: '/creators', + builder: (context, state) => const CreatorHubScreen(), + ), + GoRoute( + path: '/creators/:name/posts', builder: (context, state) { final name = state.pathParameters['name']!; return CreatorPostListScreen(pubName: name); }, ), GoRoute( - path: ':name/stickers', + path: '/creators/:name/stickers', builder: (context, state) { final name = state.pathParameters['name']!; return StickersScreen(pubName: name); }, ), GoRoute( - path: ':name/stickers/new', + path: '/creators/:name/stickers/new', builder: (context, state) { final name = state.pathParameters['name']!; return NewStickerPacksScreen(pubName: name); }, ), GoRoute( - path: ':name/stickers/:packId/edit', + path: '/creators/:name/stickers/:packId/edit', builder: (context, state) { final name = state.pathParameters['name']!; final packId = state.pathParameters['packId']!; @@ -113,7 +117,7 @@ final routerProvider = Provider((ref) { }, ), GoRoute( - path: ':name/stickers/:packId', + path: '/creators/:name/stickers/:packId', builder: (context, state) { final name = state.pathParameters['name']!; final packId = state.pathParameters['packId']!; @@ -121,14 +125,14 @@ final routerProvider = Provider((ref) { }, ), GoRoute( - path: ':name/stickers/:packId/new', + path: '/creators/:name/stickers/:packId/new', builder: (context, state) { final packId = state.pathParameters['packId']!; return NewStickersScreen(packId: packId); }, ), GoRoute( - path: ':name/stickers/:packId/:id/edit', + path: '/creators/:name/stickers/:packId/:id/edit', builder: (context, state) { final packId = state.pathParameters['packId']!; final id = state.pathParameters['id']!; @@ -136,11 +140,11 @@ final routerProvider = Provider((ref) { }, ), GoRoute( - path: 'new', + path: '/creators/new', builder: (context, state) => const NewPublisherScreen(), ), GoRoute( - path: ':name/edit', + path: '/creators/:name/edit', builder: (context, state) { final name = state.pathParameters['name']!; return EditPublisherScreen(name: name); @@ -173,56 +177,64 @@ final routerProvider = Provider((ref) { }, routes: [ // Explore tab - GoRoute( - path: '/', - builder: (context, state) => const ExploreScreen(), + ShellRoute( + builder: + (context, state, child) => ExploreShellScreen(child: child), routes: [ GoRoute( - path: 'posts/:id', + path: '/', + builder: (context, state) => const ExploreScreen(), + ), + GoRoute( + path: '/posts/:id', builder: (context, state) { final id = state.pathParameters['id']!; return PostDetailScreen(id: id); }, ), GoRoute( - path: 'publishers/:name', + path: '/publishers/:name', builder: (context, state) { final name = state.pathParameters['name']!; return PublisherProfileScreen(name: name); }, ), GoRoute( - path: 'discovery/realms', + path: '/discovery/realms', builder: (context, state) => const DiscoveryRealmsScreen(), ), ], ), // Chat tab - GoRoute( - path: '/chat', - builder: (context, state) => const ChatListScreen(), + ShellRoute( + builder: + (context, state, child) => ChatShellScreen(child: child), routes: [ GoRoute( - path: 'new', + path: '/chat', + builder: (context, state) => const ChatListScreen(), + ), + GoRoute( + path: '/chat/new', builder: (context, state) => const NewChatScreen(), ), GoRoute( - path: ':id', + path: '/chat/:id', builder: (context, state) { final id = state.pathParameters['id']!; return ChatRoomScreen(id: id); }, ), GoRoute( - path: ':id/edit', + path: '/chat/:id/edit', builder: (context, state) { final id = state.pathParameters['id']!; return EditChatScreen(id: id); }, ), GoRoute( - path: ':id/detail', + path: '/chat/:id/detail', builder: (context, state) { final id = state.pathParameters['id']!; return ChatDetailScreen(id: id); @@ -258,39 +270,43 @@ final routerProvider = Provider((ref) { ), // Account tab - GoRoute( - path: '/account', - builder: (context, state) => const AccountScreen(), + ShellRoute( + builder: + (context, state, child) => AccountShellScreen(child: child), routes: [ GoRoute( - path: 'notifications', + path: '/account', + builder: (context, state) => const AccountScreen(), + ), + GoRoute( + path: '/account/notifications', builder: (context, state) => const NotificationScreen(), ), GoRoute( - path: 'wallet', + path: '/account/wallet', builder: (context, state) => const WalletScreen(), ), GoRoute( - path: 'relationships', + path: '/account/relationships', builder: (context, state) => const RelationshipScreen(), ), GoRoute( - path: ':name', + path: '/account/:name', builder: (context, state) { final name = state.pathParameters['name']!; return AccountProfileScreen(name: name); }, ), GoRoute( - path: 'me/update', + path: '/account/me/update', builder: (context, state) => const UpdateProfileScreen(), ), GoRoute( - path: 'me/leveling', + path: '/account/me/leveling', builder: (context, state) => const LevelingScreen(), ), GoRoute( - path: 'settings', + path: '/account/settings', builder: (context, state) => const AccountSettingsScreen(), ), ], diff --git a/lib/screens/account.dart b/lib/screens/account.dart index 16a7d25..2704d00 100644 --- a/lib/screens/account.dart +++ b/lib/screens/account.dart @@ -143,7 +143,7 @@ class AccountScreen extends HookConsumerWidget { progress: user.value!.profile.levelingProgress, ), onTap: () { - context.push('/account/leveling'); + context.push('/account/me/leveling'); }, ).padding(horizontal: 12), Row( @@ -210,7 +210,7 @@ class AccountScreen extends HookConsumerWidget { contentPadding: EdgeInsets.symmetric(horizontal: 24), title: Text('wallet').tr(), onTap: () { - context.push('/wallet'); + context.push('/account/wallet'); }, ), ListTile( diff --git a/lib/screens/account/profile.dart b/lib/screens/account/profile.dart index 6e71165..956ec7d 100644 --- a/lib/screens/account/profile.dart +++ b/lib/screens/account/profile.dart @@ -53,17 +53,21 @@ Future> accountBadges(Ref ref, String uname) async { @riverpod Future accountAppbarForcegroundColor(Ref ref, String uname) async { - final account = await ref.watch(accountProvider(uname).future); - if (account.profile.background == null) return null; - final palette = await PaletteGenerator.fromImageProvider( - CloudImageWidget.provider( - fileId: account.profile.background!.id, - serverUrl: ref.watch(serverUrlProvider), - ), - ); - final dominantColor = palette.dominantColor?.color; - if (dominantColor == null) return null; - return dominantColor.computeLuminance() > 0.5 ? Colors.black : Colors.white; + try { + final account = await ref.watch(accountProvider(uname).future); + if (account.profile.background == null) return null; + final palette = await PaletteGenerator.fromImageProvider( + CloudImageWidget.provider( + fileId: account.profile.background!.id, + serverUrl: ref.watch(serverUrlProvider), + ), + ); + final dominantColor = palette.dominantColor?.color; + if (dominantColor == null) return null; + return dominantColor.computeLuminance() > 0.5 ? Colors.black : Colors.white; + } catch (_) { + return null; + } } @riverpod diff --git a/lib/screens/notification.dart b/lib/screens/notification.dart index e7a6828..6210c23 100644 --- a/lib/screens/notification.dart +++ b/lib/screens/notification.dart @@ -4,19 +4,18 @@ import 'dart:math' as math; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; -import 'package:flutter/services.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/models/user.dart'; import 'package:island/pods/network.dart'; import 'package:island/pods/websocket.dart'; -import 'package:island/widgets/alert.dart'; +import 'package:island/route.dart'; import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/content/markdown.dart'; import 'package:relative_time/relative_time.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_paging_utils/riverpod_paging_utils.dart'; import 'package:styled_widget/styled_widget.dart'; -import 'package:url_launcher/url_launcher.dart'; +import 'package:url_launcher/url_launcher_string.dart'; part 'notification.g.dart'; @@ -180,36 +179,17 @@ class NotificationScreen extends HookConsumerWidget { ), ), onTap: () { - if (notification.meta['link'] is String) { - final href = notification.meta['link']; - final uri = Uri.tryParse(href); - if (uri == null) { - showSnackBar( - 'brokenLink'.tr(args: []), - action: SnackBarAction( - label: 'copyToClipboard'.tr(), - onPressed: () { - Clipboard.setData(ClipboardData(text: href)); - clearSnackBar(context); - }, - ), + if (notification.meta['action_uri'] != null) { + var uri = notification.meta['action_uri'] as String; + if (uri.startsWith('/')) { + // In-app routes + rootNavigatorKey.currentContext?.push( + notification.meta['action_uri'], ); - return; + } else { + // External URLs + launchUrlString(uri); } - if (uri.scheme == 'solian') { - context.push( - ['', uri.host, ...uri.pathSegments].join('/'), - ); - return; - } - showConfirmAlert( - 'openLinkConfirmDescription'.tr(args: [href]), - 'openLinkConfirm'.tr(), - ).then((value) { - if (value) { - launchUrl(uri, mode: LaunchMode.externalApplication); - } - }); } }, );