diff --git a/README.md b/README.md index ba010569c..c4b82fcdd 100644 --- a/README.md +++ b/README.md @@ -82,13 +82,14 @@ height="80">](https://github.com/vitorpamplona/amethyst/releases) - [x] Private Messages and Small Groups (NIP-24) - [x] Gift Wraps & Seals (NIP-59) - [x] Versioned Encrypted Payloads (NIP-44) +- [x] Expiration Support (NIP-40) +- [x] Status Event (NIP-315) - [ ] Marketplace (NIP-15) - [ ] Image/Video Capture in the app - [ ] Local Database - [ ] Bookmarks, Pinned Posts, Muted Events (NIP-51) - [ ] Proof of Work in the Phone (NIP-13, NIP-20) - [ ] Workspaces -- [ ] Expiration Support (NIP-40) - [ ] Infinity Scroll - [ ] Relay List Metadata (NIP-65) - [ ] Signing Requests (NIP-46) diff --git a/app/build.gradle b/app/build.gradle index 61dca94ee..67fb47dbe 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -13,14 +13,14 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 34 - versionCode 278 - versionName "0.74.5" + versionCode 281 + versionName "0.75.2" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { useSupportLibrary true } - resourceConfigurations += ['ar', 'cs', 'de', 'eo', 'es', 'fa', 'fr', 'hu', 'ja', 'night', 'nl', 'pt-rBR', 'ru', 'sv-rSE', 'ta', 'tr', 'uk', 'zh', 'sh-rHK', 'zh-rTW'] + resourceConfigurations += ['ar', 'cs', 'de', 'eo', 'es', 'fa', 'fr', 'hu', 'ja', 'night', 'nl', 'pt-rBR', 'ru', 'sv-rSE', 'ta', 'th', 'tr', 'uk', 'zh', 'sh-rHK', 'zh-rTW'] } buildTypes { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 841e803ab..616d5d3c2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -62,7 +62,7 @@ android:usesCleartextTraffic="true" android:hardwareAccelerated="true" android:localeConfig="@xml/locales_config" - tools:targetApi="33"> + tools:targetApi="34"> (note.createdAt() ?: 0)) { + note.loadEvent(event, author, emptyList()) + + author.liveSet?.statuses?.invalidateData() + + refreshObservers(note) + } + } fun consume(event: BadgeDefinitionEvent) { consumeBaseReplaceable(event) } @@ -1088,7 +1112,7 @@ object LocalCache { } return notes.values.filter { - (it.event !is GenericRepostEvent && it.event !is RepostEvent && it.event !is CommunityPostApprovalEvent && it.event !is ReactionEvent && it.event !is GiftWrapEvent) && + (it.event !is GenericRepostEvent && it.event !is RepostEvent && it.event !is CommunityPostApprovalEvent && it.event !is ReactionEvent && it.event !is GiftWrapEvent && it.event !is LnZapEvent && it.event !is LnZapRequestEvent) && ( it.event?.content()?.contains(text, true) ?: false || it.event?.matchTag1With(text) ?: false || @@ -1096,7 +1120,7 @@ object LocalCache { it.idNote().startsWith(text, true) ) } + addressables.values.filter { - (it.event !is GenericRepostEvent && it.event !is RepostEvent && it.event !is CommunityPostApprovalEvent && it.event !is ReactionEvent && it.event !is GiftWrapEvent) && + (it.event !is GenericRepostEvent && it.event !is RepostEvent && it.event !is CommunityPostApprovalEvent && it.event !is ReactionEvent && it.event !is GiftWrapEvent && it.event !is LnZapEvent && it.event !is LnZapRequestEvent) && ( it.event?.content()?.contains(text, true) ?: false || it.event?.matchTag1With(text) ?: false || @@ -1125,6 +1149,18 @@ object LocalCache { } } + suspend fun findStatusesForUser(user: User): ImmutableList { + checkNotInMainThread() + + return addressables.filter { + val noteEvent = it.value.event + (noteEvent is StatusEvent && noteEvent.pubKey == user.pubkeyHex && !noteEvent.isExpired() && noteEvent.content.isNotBlank()) + }.values + .sortedWith(compareBy({ it.event?.expiration() ?: it.event?.createdAt() }, { it.idHex })) + .reversed() + .toImmutableList() + } + fun cleanObservers() { notes.forEach { it.value.clearLive() @@ -1268,6 +1304,39 @@ object LocalCache { } } + fun pruneExpiredEvents() { + checkNotInMainThread() + + val now = TimeUtils.now() + + val toBeRemoved = notes.filter { + it.value.event?.isExpired() == true + }.values + + val childrenToBeRemoved = mutableListOf() + + toBeRemoved.forEach { + notes.remove(it.idHex) + + it.replyTo?.forEach { masterNote -> + masterNote.removeReply(it) + masterNote.removeBoost(it) + masterNote.removeReaction(it) + masterNote.removeZap(it) + masterNote.removeReport(it) + masterNote.clearEOSE() // allows reloading of these events + } + + childrenToBeRemoved.addAll(it.removeAllChildNotes()) + } + + removeChildrenOf(childrenToBeRemoved) + + if (toBeRemoved.size > 1) { + println("PRUNE: ${toBeRemoved.size} thread replies removed.") + } + } + fun pruneHiddenMessages(account: Account) { checkNotInMainThread() @@ -1423,6 +1492,7 @@ object LocalCache { is PrivateDmEvent -> consume(event, relay) is PinListEvent -> consume(event) is PeopleListEvent -> consume(event) + is PollNoteEvent -> consume(event, relay) is ReactionEvent -> consume(event) is RecommendRelayEvent -> consume(event) is RelaySetEvent -> consume(event) @@ -1439,8 +1509,9 @@ object LocalCache { } consume(event) } + is StatusEvent -> consume(event, relay) is TextNoteEvent -> consume(event, relay) - is PollNoteEvent -> consume(event, relay) + else -> { Log.w("Event Not Supported", event.toJson()) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt index e9237ab6e..3a4ce8925 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt @@ -3,6 +3,8 @@ package com.vitorpamplona.amethyst.model import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import androidx.lifecycle.LiveData +import androidx.lifecycle.distinctUntilChanged +import androidx.lifecycle.map import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.firstFullCharOrEmoji @@ -10,6 +12,7 @@ import com.vitorpamplona.amethyst.service.relays.EOSETime import com.vitorpamplona.amethyst.service.relays.Relay import com.vitorpamplona.amethyst.ui.actions.updated import com.vitorpamplona.amethyst.ui.components.BundledUpdate +import com.vitorpamplona.amethyst.ui.note.combineWith import com.vitorpamplona.amethyst.ui.note.toShortenHex import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.encoders.Hex @@ -651,10 +654,18 @@ open class Note(val idHex: String) { } } +@Stable class NoteLiveSet(u: Note) { // Observers line up here. val metadata: NoteLiveData = NoteLiveData(u) + val authorChanges = metadata.map { + it.note.author + } + val hasEvent = metadata.map { + it.note.event != null + }.distinctUntilChanged() + val reactions: NoteLiveData = NoteLiveData(u) val boosts: NoteLiveData = NoteLiveData(u) val replies: NoteLiveData = NoteLiveData(u) @@ -662,6 +673,20 @@ class NoteLiveSet(u: Note) { val relays: NoteLiveData = NoteLiveData(u) val zaps: NoteLiveData = NoteLiveData(u) + val hasReactions = zaps.combineWith(boosts, reactions) { zapState, boostState, reactionState -> + zapState?.note?.zaps?.isNotEmpty() ?: false || + boostState?.note?.boosts?.isNotEmpty() ?: false || + reactionState?.note?.reactions?.isNotEmpty() ?: false + }.distinctUntilChanged() + + val replyCount = replies.map { + it.note.replies.size + }.distinctUntilChanged() + + val boostCount = boosts.map { + it.note.boosts.size + }.distinctUntilChanged() + fun isInUse(): Boolean { return metadata.hasObservers() || reactions.hasObservers() || diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt index f2ee77c99..d46bdcc76 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt @@ -3,6 +3,8 @@ package com.vitorpamplona.amethyst.model import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import androidx.lifecycle.LiveData +import androidx.lifecycle.distinctUntilChanged +import androidx.lifecycle.map import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.relays.EOSETime @@ -378,6 +380,7 @@ class User(val pubkeyHex: String) { } } +@Stable class UserLiveSet(u: User) { // UI Observers line up here. val follows: UserLiveData = UserLiveData(u) @@ -389,6 +392,19 @@ class UserLiveSet(u: User) { val metadata: UserLiveData = UserLiveData(u) val zaps: UserLiveData = UserLiveData(u) val bookmarks: UserLiveData = UserLiveData(u) + val statuses: UserLiveData = UserLiveData(u) + + val profilePictureChanges = metadata.map { + it.user.profilePicture() + }.distinctUntilChanged() + + val nip05Changes = metadata.map { + it.user.nip05() + }.distinctUntilChanged() + + val userMetadataInfo = metadata.map { + it.user.info + }.distinctUntilChanged() fun isInUse(): Boolean { return follows.hasObservers() || @@ -399,6 +415,7 @@ class UserLiveSet(u: User) { relayInfo.hasObservers() || metadata.hasObservers() || zaps.hasObservers() || + statuses.hasObservers() || bookmarks.hasObservers() } @@ -412,6 +429,7 @@ class UserLiveSet(u: User) { metadata.destroy() zaps.destroy() bookmarks.destroy() + statuses.destroy() } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt index 10efa074e..9a91ccaf2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt @@ -63,9 +63,9 @@ object NostrAccountDataSource : NostrDataSource("AccountData") { return TypedFilter( types = COMMON_FEED_TYPES, filter = JsonFilter( - kinds = listOf(AdvertisedRelayListEvent.kind), + kinds = listOf(AdvertisedRelayListEvent.kind, StatusEvent.kind), authors = listOf(account.userProfile().pubkeyHex), - limit = 1 + limit = 5 ) ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleUserDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleUserDataSource.kt index 47de281e7..5dfa266f6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleUserDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleUserDataSource.kt @@ -7,6 +7,7 @@ import com.vitorpamplona.amethyst.service.relays.JsonFilter import com.vitorpamplona.amethyst.service.relays.TypedFilter import com.vitorpamplona.quartz.events.MetadataEvent import com.vitorpamplona.quartz.events.ReportEvent +import com.vitorpamplona.quartz.events.StatusEvent object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") { var usersToWatch = setOf() @@ -26,6 +27,21 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") { } } + fun createUserStatusFilter(): List? { + if (usersToWatch.isEmpty()) return null + + return usersToWatch.map { + TypedFilter( + types = COMMON_FEED_TYPES, + filter = JsonFilter( + kinds = listOf(StatusEvent.kind), + authors = listOf(it.pubkeyHex), + since = it.latestEOSEs + ) + ) + } + } + fun createUserReportFilter(): List? { if (usersToWatch.isEmpty()) return null @@ -59,8 +75,8 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") { val userChannelOnce = requestNewChannel() override fun updateChannelFilters() { - userChannel.typedFilters = listOfNotNull(createUserFilter()).flatten().ifEmpty { null } - userChannelOnce.typedFilters = listOfNotNull(createUserReportFilter()).flatten().ifEmpty { null } + userChannel.typedFilters = listOfNotNull(createUserReportFilter(), createUserStatusFilter()).flatten().ifEmpty { null } + userChannelOnce.typedFilters = listOfNotNull(createUserFilter()).flatten().ifEmpty { null } } fun add(user: User) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt index 9acfc0485..7ff65c5e1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt @@ -120,7 +120,7 @@ fun JoinUserOrChannelView(searchBarViewModel: SearchBarViewModel, onClose: () -> horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - CloseButton(onCancel = { + CloseButton(onPress = { searchBarViewModel.clear() NostrSearchEventOrUserDataSource.clear() onClose() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelView.kt index a5769d463..db732c870 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelView.kt @@ -51,7 +51,7 @@ fun NewChannelView(onClose: () -> Unit, accountViewModel: AccountViewModel, chan horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - CloseButton(onCancel = { + CloseButton(onPress = { postViewModel.clear() onClose() }) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt index fca593fd7..b4344132c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt @@ -123,7 +123,7 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - CloseButton(onCancel = { + CloseButton(onPress = { postViewModel.cancel() onClose() }) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt index 59f43ba68..23c1f6073 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt @@ -100,6 +100,7 @@ import com.vitorpamplona.amethyst.ui.theme.QuoteBorder import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.replyModifier @@ -188,316 +189,340 @@ fun NewPostView( decorFitsSystemWindows = false ) ) { - Surface( - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight() - ) { - if (showRelaysDialog) { - RelaySelectionDialog( - list = relayList, - onClose = { - showRelaysDialog = false - }, - onPost = { - relayList = it - }, - accountViewModel = accountViewModel, - nav = nav - ) - } + if (showRelaysDialog) { + RelaySelectionDialog( + list = relayList, + onClose = { + showRelaysDialog = false + }, + onPost = { + relayList = it + }, + accountViewModel = accountViewModel, + nav = nav + ) + } - Column( - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight() - ) { - Column( - modifier = Modifier - .padding(start = 10.dp, end = 10.dp, top = 10.dp) - .imePadding() - .weight(1f) - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - CloseButton(onCancel = { + Scaffold( + topBar = { + TopAppBar( + title = { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(end = 10.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Spacer(modifier = StdHorzSpacer) + + Box { + IconButton( + modifier = Modifier.align(Alignment.Center), + onClick = { + showRelaysDialog = true + } + ) { + Icon( + painter = painterResource(R.drawable.relays), + contentDescription = null, + modifier = Modifier.height(25.dp), + tint = MaterialTheme.colors.onBackground + ) + } + } + PostButton( + onPost = { + scope.launch(Dispatchers.IO) { + postViewModel.sendPost(relayList = relayList) + onClose() + } + }, + isActive = postViewModel.canPost() + ) + } + }, + navigationIcon = { + Spacer(modifier = StdHorzSpacer) + CloseButton(onPress = { postViewModel.cancel() onClose() }) - - Box { - IconButton( - modifier = Modifier.align(Alignment.Center), - onClick = { - showRelaysDialog = true - } - ) { - Icon( - painter = painterResource(R.drawable.relays), - contentDescription = null, - modifier = Modifier.height(25.dp), - tint = MaterialTheme.colors.onBackground - ) - } - } - PostButton( - onPost = { - scope.launch(Dispatchers.IO) { - event = postViewModel.sendPost(relayList = relayList, signEvent = account.keyPair.privKey != null) - if (event == null) { - onClose() - } - } - }, - isActive = postViewModel.canPost() - ) - } - - Row( + }, + backgroundColor = MaterialTheme.colors.surface, + elevation = 0.dp + ) + } + ) { pad -> + Surface( + modifier = Modifier + .padding( + start = Size10dp, + top = pad.calculateTopPadding(), + end = Size10dp, + bottom = pad.calculateBottomPadding() + ) + .fillMaxSize() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + ) { + Column( modifier = Modifier - .fillMaxWidth() + .padding(top = 10.dp) + .imePadding() .weight(1f) ) { - Column( + Row( modifier = Modifier .fillMaxWidth() - .verticalScroll(scrollState) + .weight(1f) ) { - postViewModel.originalNote?.let { - NoteCompose( - baseNote = it, - makeItShort = true, - unPackReply = false, - isQuotedNote = true, - modifier = MaterialTheme.colors.replyModifier, - accountViewModel = accountViewModel, - nav = nav - ) - } - - Notifying(postViewModel.mentions?.toImmutableList()) { - postViewModel.removeFromReplyList(it) - } - - if (enableMessageInterface) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp) - ) { - SendDirectMessageTo(postViewModel = postViewModel) + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(scrollState) + ) { + postViewModel.originalNote?.let { + Row(Modifier.heightIn(max = 200.dp)) { + NoteCompose( + baseNote = it, + makeItShort = true, + unPackReply = false, + isQuotedNote = true, + modifier = MaterialTheme.colors.replyModifier, + accountViewModel = accountViewModel, + nav = nav + ) + Spacer(modifier = StdVertSpacer) + } } - } - MessageField(postViewModel) - - if (postViewModel.wantsPoll) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp) - ) { - PollField(postViewModel) + Row() { + Notifying(postViewModel.mentions?.toImmutableList()) { + postViewModel.removeFromReplyList(it) + } } - } - if (postViewModel.wantsToMarkAsSensitive) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .padding(vertical = Size5dp, horizontal = Size10dp) - ) { - ContentSensitivityExplainer(postViewModel) + if (enableMessageInterface) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp) + ) { + SendDirectMessageTo(postViewModel = postViewModel) + } } - } - if (postViewModel.wantsToAddGeoHash) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .padding(vertical = Size5dp, horizontal = Size10dp) - ) { - LocationAsHash(postViewModel) + MessageField(postViewModel) + + if (postViewModel.wantsPoll) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp) + ) { + PollField(postViewModel) + } } - } - if (postViewModel.wantsForwardZapTo) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp) - ) { - FowardZapTo(postViewModel) + if (postViewModel.wantsToMarkAsSensitive) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding(vertical = Size5dp, horizontal = Size10dp) + ) { + ContentSensitivityExplainer(postViewModel) + } } - } - val url = postViewModel.contentToAddUrl - if (url != null) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)) { - ImageVideoDescription( - url, - account.defaultFileServer, - onAdd = { description, server, sensitiveContent -> - postViewModel.upload(url, description, sensitiveContent, server, context, relayList) - account.changeDefaultFileServer(server) - }, - onCancel = { - postViewModel.contentToAddUrl = null - }, - onError = { - scope.launch { - postViewModel.imageUploadingError.emit(it) - } - }, - accountViewModel = accountViewModel - ) + if (postViewModel.wantsToAddGeoHash) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding(vertical = Size5dp, horizontal = Size10dp) + ) { + LocationAsHash(postViewModel) + } } - } - val user = postViewModel.account?.userProfile() - val lud16 = user?.info?.lnAddress() + if (postViewModel.wantsForwardZapTo) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp) + ) { + FowardZapTo(postViewModel) + } + } - if (lud16 != null && postViewModel.wantsInvoice) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)) { - Column(Modifier.fillMaxWidth()) { - InvoiceRequest( - lud16, - user.pubkeyHex, - account, - stringResource(id = R.string.lightning_invoice), - stringResource(id = R.string.lightning_create_and_add_invoice), - onSuccess = { - postViewModel.message = TextFieldValue(postViewModel.message.text + "\n\n" + it) - postViewModel.wantsInvoice = false + val url = postViewModel.contentToAddUrl + if (url != null) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)) { + ImageVideoDescription( + url, + account.defaultFileServer, + onAdd = { description, server, sensitiveContent -> + postViewModel.upload(url, description, sensitiveContent, server, context, relayList) + account.changeDefaultFileServer(server) }, - onClose = { - postViewModel.wantsInvoice = false - } + onCancel = { + postViewModel.contentToAddUrl = null + }, + onError = { + scope.launch { + postViewModel.imageUploadingError.emit(it) + } + }, + accountViewModel = accountViewModel ) } } - } - if (lud16 != null && postViewModel.wantsZapraiser) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)) { - ZapRaiserRequest( - stringResource(id = R.string.zapraiser), - postViewModel - ) - } - } + val user = postViewModel.account?.userProfile() + val lud16 = user?.info?.lnAddress() - val myUrlPreview = postViewModel.urlPreview - if (myUrlPreview != null) { - Row(modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)) { - if (isValidURL(myUrlPreview)) { - val removedParamsFromUrl = - myUrlPreview.split("?")[0].lowercase() - if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { - AsyncImage( - model = myUrlPreview, - contentDescription = myUrlPreview, - contentScale = ContentScale.FillWidth, - modifier = Modifier - .padding(top = 4.dp) - .fillMaxWidth() - .clip(shape = QuoteBorder) - .border( - 1.dp, - MaterialTheme.colors.subtleBorder, - QuoteBorder - ) + if (lud16 != null && postViewModel.wantsInvoice) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)) { + Column(Modifier.fillMaxWidth()) { + InvoiceRequest( + lud16, + user.pubkeyHex, + account, + stringResource(id = R.string.lightning_invoice), + stringResource(id = R.string.lightning_create_and_add_invoice), + onSuccess = { + postViewModel.message = TextFieldValue(postViewModel.message.text + "\n\n" + it) + postViewModel.wantsInvoice = false + }, + onClose = { + postViewModel.wantsInvoice = false + } ) - } else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) { - VideoView(myUrlPreview, roundedCorner = true, accountViewModel = accountViewModel) - } else { - UrlPreview(myUrlPreview, myUrlPreview, accountViewModel) - } - } else if (startsWithNIP19Scheme(myUrlPreview)) { - val bgColor = MaterialTheme.colors.background - val backgroundColor = remember { - mutableStateOf(bgColor) } + } + } - BechLink( - myUrlPreview, - true, - backgroundColor, - accountViewModel, - nav + if (lud16 != null && postViewModel.wantsZapraiser) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)) { + ZapRaiserRequest( + stringResource(id = R.string.zapraiser), + postViewModel ) - } else if (noProtocolUrlValidator.matcher(myUrlPreview).matches()) { - UrlPreview("https://$myUrlPreview", myUrlPreview, accountViewModel) + } + } + + val myUrlPreview = postViewModel.urlPreview + if (myUrlPreview != null) { + Row(modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)) { + if (isValidURL(myUrlPreview)) { + val removedParamsFromUrl = + myUrlPreview.split("?")[0].lowercase() + if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { + AsyncImage( + model = myUrlPreview, + contentDescription = myUrlPreview, + contentScale = ContentScale.FillWidth, + modifier = Modifier + .padding(top = 4.dp) + .fillMaxWidth() + .clip(shape = QuoteBorder) + .border( + 1.dp, + MaterialTheme.colors.subtleBorder, + QuoteBorder + ) + ) + } else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) { + VideoView(myUrlPreview, roundedCorner = true, accountViewModel = accountViewModel) + } else { + UrlPreview(myUrlPreview, myUrlPreview, accountViewModel) + } + } else if (startsWithNIP19Scheme(myUrlPreview)) { + val bgColor = MaterialTheme.colors.background + val backgroundColor = remember { + mutableStateOf(bgColor) + } + + BechLink( + myUrlPreview, + true, + backgroundColor, + accountViewModel, + nav + ) + } else if (noProtocolUrlValidator.matcher(myUrlPreview).matches()) { + UrlPreview("https://$myUrlPreview", myUrlPreview, accountViewModel) + } } } } } - } - val userSuggestions = postViewModel.userSuggestions - if (userSuggestions.isNotEmpty()) { - LazyColumn( - contentPadding = PaddingValues( - top = 10.dp - ), - modifier = Modifier.heightIn(0.dp, 300.dp) - ) { - itemsIndexed( - userSuggestions, - key = { _, item -> item.pubkeyHex } - ) { _, item -> - UserLine(item, accountViewModel) { - postViewModel.autocompleteWithUser(item) + val userSuggestions = postViewModel.userSuggestions + if (userSuggestions.isNotEmpty()) { + LazyColumn( + contentPadding = PaddingValues( + top = 10.dp + ), + modifier = Modifier.heightIn(0.dp, 300.dp) + ) { + itemsIndexed( + userSuggestions, + key = { _, item -> item.pubkeyHex } + ) { _, item -> + UserLine(item, accountViewModel) { + postViewModel.autocompleteWithUser(item) + } } } } - } - Row( - modifier = Modifier - .fillMaxWidth() - .height(50.dp), - verticalAlignment = Alignment.CenterVertically - ) { - UploadFromGallery( - isUploading = postViewModel.isUploadingImage, - tint = MaterialTheme.colors.onBackground, + Row( modifier = Modifier + .fillMaxWidth() + .height(50.dp), + verticalAlignment = Alignment.CenterVertically ) { - postViewModel.selectImage(it) - } - - if (postViewModel.canUsePoll) { - // These should be hashtag recommendations the user selects in the future. - // val hashtag = stringResource(R.string.poll_hashtag) - // postViewModel.includePollHashtagInMessage(postViewModel.wantsPoll, hashtag) - AddPollButton(postViewModel.wantsPoll) { - postViewModel.wantsPoll = !postViewModel.wantsPoll + UploadFromGallery( + isUploading = postViewModel.isUploadingImage, + tint = MaterialTheme.colors.onBackground, + modifier = Modifier + ) { + postViewModel.selectImage(it) } - } - if (postViewModel.canAddInvoice) { - AddLnInvoiceButton(postViewModel.wantsInvoice) { - postViewModel.wantsInvoice = !postViewModel.wantsInvoice + if (postViewModel.canUsePoll) { + // These should be hashtag recommendations the user selects in the future. + // val hashtag = stringResource(R.string.poll_hashtag) + // postViewModel.includePollHashtagInMessage(postViewModel.wantsPoll, hashtag) + AddPollButton(postViewModel.wantsPoll) { + postViewModel.wantsPoll = !postViewModel.wantsPoll + } } - } - if (postViewModel.canAddZapRaiser) { - AddZapraiserButton(postViewModel.wantsZapraiser) { - postViewModel.wantsZapraiser = !postViewModel.wantsZapraiser + if (postViewModel.canAddInvoice) { + AddLnInvoiceButton(postViewModel.wantsInvoice) { + postViewModel.wantsInvoice = !postViewModel.wantsInvoice + } } - } - MarkAsSensitive(postViewModel) { - postViewModel.wantsToMarkAsSensitive = !postViewModel.wantsToMarkAsSensitive - } + if (postViewModel.canAddZapRaiser) { + AddZapraiserButton(postViewModel.wantsZapraiser) { + postViewModel.wantsZapraiser = !postViewModel.wantsZapraiser + } + } - AddGeoHash(postViewModel) { - postViewModel.wantsToAddGeoHash = !postViewModel.wantsToAddGeoHash - } + MarkAsSensitive(postViewModel) { + postViewModel.wantsToMarkAsSensitive = !postViewModel.wantsToMarkAsSensitive + } - ForwardZapTo(postViewModel) { - postViewModel.wantsForwardZapTo = !postViewModel.wantsForwardZapTo + AddGeoHash(postViewModel) { + postViewModel.wantsToAddGeoHash = !postViewModel.wantsToAddGeoHash + } + + ForwardZapTo(postViewModel) { + postViewModel.wantsForwardZapTo = !postViewModel.wantsForwardZapTo + } } } } @@ -1183,10 +1208,10 @@ private fun MarkAsSensitive( } @Composable -fun CloseButton(onCancel: () -> Unit) { +fun CloseButton(onPress: () -> Unit) { Button( onClick = { - onCancel() + onPress() }, shape = ButtonBorder, colors = ButtonDefaults diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListView.kt index 037431a32..2a7bab0aa 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListView.kt @@ -157,7 +157,7 @@ fun NewRelayListView(onClose: () -> Unit, accountViewModel: AccountViewModel, re }, navigationIcon = { Spacer(modifier = StdHorzSpacer) - CloseButton(onCancel = { + CloseButton(onPress = { postViewModel.clear() onClose() }) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataView.kt index 33b6d37a8..212747f18 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataView.kt @@ -97,7 +97,7 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) { horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - CloseButton(onCancel = { + CloseButton(onPress = { postViewModel.clear() onClose() }) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/RelayInformationDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/RelayInformationDialog.kt index 790a7ea33..952f455e0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/RelayInformationDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/RelayInformationDialog.kt @@ -74,7 +74,7 @@ fun RelayInformationDialog( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - CloseButton(onCancel = { + CloseButton(onPress = { onClose() }) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/RelaySelectionDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/RelaySelectionDialog.kt index 46fe688d0..e2d4af042 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/RelaySelectionDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/RelaySelectionDialog.kt @@ -110,7 +110,7 @@ fun RelaySelectionDialog( verticalAlignment = Alignment.CenterVertically ) { CloseButton( - onCancel = { + onPress = { onClose() } ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index 95450735e..df9a345e7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -30,8 +30,6 @@ import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.em -import androidx.lifecycle.distinctUntilChanged -import androidx.lifecycle.map import com.halilibo.richtext.markdown.Markdown import com.halilibo.richtext.markdown.MarkdownParseOptions import com.halilibo.richtext.ui.material.MaterialRichText @@ -421,9 +419,7 @@ private fun ObserveNIP19Event( @Composable fun ObserveNote(note: Note, onRefresh: () -> Unit) { - val loadedNoteId by note.live().metadata.map { - it.note.event?.id() - }.distinctUntilChanged().observeAsState(note.event?.id()) + val loadedNoteId by note.live().metadata.observeAsState() LaunchedEffect(key1 = loadedNoteId) { if (loadedNoteId != null) { @@ -460,9 +456,7 @@ private fun ObserveNIP19User( @Composable private fun ObserveUser(user: User, onRefresh: () -> Unit) { - val loadedUserMetaId by user.live().metadata.map { - it.user.info?.latestMetadata?.id - }.distinctUntilChanged().observeAsState(user.info?.latestMetadata?.id) + val loadedUserMetaId by user.live().metadata.observeAsState() LaunchedEffect(key1 = loadedUserMetaId) { if (loadedUserMetaId != null) { @@ -854,9 +848,7 @@ private fun DisplayUserFromTag( val route = remember { "User/${baseUser.pubkeyHex}" } val hex = remember { baseUser.pubkeyDisplayHex() } - val meta by baseUser.live().metadata.map { - it.user.info - }.distinctUntilChanged().observeAsState(baseUser.info) + val meta by baseUser.live().userMetadataInfo.observeAsState(baseUser.info) Crossfade(targetState = meta) { Row() { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 373f24214..def9f9f4d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -663,7 +663,7 @@ fun ZoomableImageDialog( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - CloseButton(onCancel = onDismiss) + CloseButton(onPress = onDismiss) val myContent = allImages[pagerState.currentPage] if (myContent is ZoomableUrlContent) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt index 184092bab..f180aaef9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt @@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.AlertDialog import androidx.compose.material.Divider @@ -27,13 +28,18 @@ import androidx.compose.material.Icon import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme import androidx.compose.material.ModalBottomSheetState +import androidx.compose.material.OutlinedTextField import androidx.compose.material.ScaffoldState import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.material.TextButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Send import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf @@ -46,13 +52,15 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.lifecycle.distinctUntilChanged +import androidx.lifecycle.map import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.AsyncImage import com.vitorpamplona.amethyst.BuildConfig @@ -65,12 +73,16 @@ import com.vitorpamplona.amethyst.service.HttpClient import com.vitorpamplona.amethyst.ui.actions.NewRelayListView import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy +import com.vitorpamplona.amethyst.ui.note.LoadStatuses import com.vitorpamplona.amethyst.ui.screen.RelayPoolViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountBackupDialog import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.ConnectOrbotDialog import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size16dp +import com.vitorpamplona.amethyst.ui.theme.Size20Modifier +import com.vitorpamplona.amethyst.ui.theme.Size26Modifier import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.events.toImmutableListOfLists import kotlinx.coroutines.CoroutineScope @@ -97,6 +109,7 @@ fun DrawerContent( .padding(horizontal = 25.dp) .padding(top = 100.dp), scaffoldState, + accountViewModel, nav ) Divider( @@ -123,6 +136,7 @@ fun ProfileContent( baseAccountUser: User, modifier: Modifier = Modifier, scaffoldState: ScaffoldState, + accountViewModel: AccountViewModel, nav: (String) -> Unit ) { val coroutineScope = rememberCoroutineScope() @@ -215,9 +229,14 @@ fun ProfileContent( ) ) } + + Row(Modifier.padding(top = Size10dp)) { + EditStatusBox(baseAccountUser, accountViewModel) + } + Row( modifier = Modifier - .padding(top = 15.dp) + .padding(top = Size10dp) .clickable(onClick = { nav(route) coroutineScope.launch { @@ -231,6 +250,132 @@ fun ProfileContent( } } +@Composable +private fun EditStatusBox(baseAccountUser: User, accountViewModel: AccountViewModel) { + val scope = rememberCoroutineScope() + val focusManager = LocalFocusManager.current + + LoadStatuses(user = baseAccountUser) { statuses -> + if (statuses.isEmpty()) { + val currentStatus = remember { + mutableStateOf("") + } + val hasChanged by remember { + derivedStateOf { + currentStatus.value != "" + } + } + + OutlinedTextField( + value = currentStatus.value, + onValueChange = { currentStatus.value = it }, + label = { Text(text = stringResource(R.string.status_update)) }, + modifier = Modifier.fillMaxWidth(), + placeholder = { + Text( + text = stringResource(R.string.status_update), + color = MaterialTheme.colors.placeholderText + ) + }, + keyboardOptions = KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences + ), + singleLine = true, + trailingIcon = { + if (hasChanged) { + UserStatusSendButton() { + scope.launch(Dispatchers.IO) { + accountViewModel.createStatus(currentStatus.value) + focusManager.clearFocus(true) + } + } + } + } + ) + } else { + statuses.forEach { + val originalStatus by it.live().metadata.map { + it.note.event?.content() ?: "" + }.observeAsState(it.event?.content() ?: "") + + val thisStatus = remember { + mutableStateOf(originalStatus) + } + val hasChanged by remember { + derivedStateOf { + thisStatus.value != originalStatus + } + } + + OutlinedTextField( + value = thisStatus.value, + onValueChange = { thisStatus.value = it }, + label = { Text(text = stringResource(R.string.status_update)) }, + modifier = Modifier.fillMaxWidth(), + placeholder = { + Text( + text = stringResource(R.string.status_update), + color = MaterialTheme.colors.placeholderText + ) + }, + keyboardOptions = KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences + ), + singleLine = true, + trailingIcon = { + if (hasChanged) { + UserStatusSendButton() { + scope.launch(Dispatchers.IO) { + accountViewModel.updateStatus(it, thisStatus.value) + focusManager.clearFocus(true) + } + } + } else { + UserStatusDeleteButton() { + scope.launch(Dispatchers.IO) { + accountViewModel.updateStatus(it, "") + accountViewModel.delete(it) + focusManager.clearFocus(true) + } + } + } + } + ) + } + } + } +} + +@Composable +fun UserStatusSendButton(onClick: () -> Unit) { + IconButton( + modifier = Size26Modifier, + onClick = onClick + ) { + Icon( + imageVector = Icons.Default.Send, + null, + modifier = Size20Modifier, + tint = MaterialTheme.colors.placeholderText + ) + } +} + +@Composable +fun UserStatusDeleteButton(onClick: () -> Unit) { + IconButton( + modifier = Size26Modifier, + onClick = onClick + ) { + Icon( + imageVector = Icons.Default.Delete, + null, + modifier = Size20Modifier, + tint = MaterialTheme.colors.placeholderText + ) + } +} + @Composable private fun FollowingAndFollowerCounts(baseAccountUser: User) { var followingCount by remember { mutableStateOf("--") } @@ -484,7 +629,7 @@ private fun RelayStatus( relayViewModel: RelayPoolViewModel ) { val connectedRelaysText by relayViewModel.connectionStatus.observeAsState("--/--") - val isConnected by relayViewModel.isConnected.distinctUntilChanged().observeAsState(false) + val isConnected by relayViewModel.isConnected.observeAsState(false) RenderRelayStatus(connectedRelaysText, isConnected) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/AddBountyAmountDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/AddBountyAmountDialog.kt index cfece88ef..2f57f3479 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/AddBountyAmountDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/AddBountyAmountDialog.kt @@ -95,7 +95,7 @@ fun AddBountyAmountDialog(bounty: Note, accountViewModel: AccountViewModel, onCl verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth() ) { - CloseButton(onCancel = { + CloseButton(onPress = { postViewModel.cancel() onClose() }) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt index c6aef6fad..5df050bf0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt @@ -93,24 +93,10 @@ fun ChannelCardCompose( accountViewModel: AccountViewModel, nav: (String) -> Unit ) { - val isBlank by baseNote.live().metadata.map { - it.note.event == null - }.distinctUntilChanged().observeAsState(baseNote.event == null) + val hasEvent by baseNote.live().hasEvent.observeAsState(baseNote.event != null) - Crossfade(targetState = isBlank) { + Crossfade(targetState = hasEvent) { if (it) { - LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup -> - BlankNote( - remember { - modifier.combinedClickable( - onClick = { }, - onLongClick = showPopup - ) - }, - false - ) - } - } else { if (forceEventKind == null || baseNote.event?.kind() == forceEventKind) { CheckHiddenChannelCardCompose( baseNote, @@ -122,6 +108,18 @@ fun ChannelCardCompose( nav ) } + } else { + LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup -> + BlankNote( + remember { + modifier.combinedClickable( + onClick = { }, + onLongClick = showPopup + ) + }, + false + ) + } } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomHeaderCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomHeaderCompose.kt index 2979f033b..f0a2c050f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomHeaderCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomHeaderCompose.kt @@ -77,14 +77,12 @@ fun ChatroomHeaderCompose( accountViewModel: AccountViewModel, nav: (String) -> Unit ) { - val isBlank by baseNote.live().metadata.map { - it.note.event == null - }.observeAsState(baseNote.event == null) + val hasEvent by baseNote.live().hasEvent.observeAsState(baseNote.event != null) - if (isBlank) { - BlankNote(Modifier) - } else { + if (hasEvent) { ChatroomComposeChannelOrUser(baseNote, accountViewModel, nav) + } else { + BlankNote(Modifier) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt index ab592f0da..b9bdeb5a9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt @@ -90,12 +90,20 @@ fun ChatroomMessageCompose( nav: (String) -> Unit, onWantsToReply: (Note) -> Unit ) { - val isBlank by baseNote.live().metadata.map { - it.note.event == null - }.observeAsState(baseNote.event == null) + val hasEvent by baseNote.live().hasEvent.observeAsState(baseNote.event != null) - Crossfade(targetState = isBlank) { + Crossfade(targetState = hasEvent) { if (it) { + CheckHiddenChatMessage( + baseNote, + routeForLastRead, + innerQuote, + parentBackgroundColor, + accountViewModel, + nav, + onWantsToReply + ) + } else { LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup -> BlankNote( remember { @@ -106,16 +114,6 @@ fun ChatroomMessageCompose( } ) } - } else { - CheckHiddenChatMessage( - baseNote, - routeForLastRead, - innerQuote, - parentBackgroundColor, - accountViewModel, - nav, - onWantsToReply - ) } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt index 05c679f99..618e052bf 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt @@ -41,8 +41,6 @@ import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.lifecycle.distinctUntilChanged -import androidx.lifecycle.map import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note @@ -579,9 +577,7 @@ private fun WatchNoteAuthor( baseNote: Note, onContent: @Composable (User?) -> Unit ) { - val author by baseNote.live().metadata.map { - it.note.author - }.observeAsState(baseNote.author) + val author by baseNote.live().authorChanges.observeAsState(baseNote.author) onContent(author) } @@ -591,9 +587,7 @@ private fun WatchUserMetadata( author: User, onNewMetadata: @Composable (String?) -> Unit ) { - val userProfile by author.live().metadata.map { - it.user.profilePicture() - }.distinctUntilChanged().observeAsState(author.profilePicture()) + val userProfile by author.live().profilePictureChanges.observeAsState(author.profilePicture()) onNewMetadata(userProfile) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt index 412241c8c..2acdcfaff 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt @@ -5,38 +5,58 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.ClickableText +import androidx.compose.material.Icon +import androidx.compose.material.IconButton import androidx.compose.material.LocalTextStyle import androidx.compose.material.MaterialTheme import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.OpenInNew import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.lifecycle.map +import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.Nip05Verifier +import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote +import com.vitorpamplona.amethyst.ui.note.LoadStatuses import com.vitorpamplona.amethyst.ui.note.NIP05CheckingIcon import com.vitorpamplona.amethyst.ui.note.NIP05FailedVerification import com.vitorpamplona.amethyst.ui.note.NIP05VerifiedIcon +import com.vitorpamplona.amethyst.ui.note.routeFor +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.Font14SP import com.vitorpamplona.amethyst.ui.theme.NIP05IconSize +import com.vitorpamplona.amethyst.ui.theme.Size15Modifier import com.vitorpamplona.amethyst.ui.theme.Size16Modifier +import com.vitorpamplona.amethyst.ui.theme.Size18Modifier +import com.vitorpamplona.amethyst.ui.theme.lessImportantLink import com.vitorpamplona.amethyst.ui.theme.nip05 import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.events.AddressableEvent import com.vitorpamplona.quartz.events.UserMetadata import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.seconds @Composable fun nip05VerificationAsAState(userMetadata: UserMetadata, pubkeyHex: String): MutableState { @@ -93,47 +113,193 @@ fun nip05VerificationAsAState(userMetadata: UserMetadata, pubkeyHex: String): Mu } @Composable -fun ObserveDisplayNip05Status(baseNote: Note, columnModifier: Modifier = Modifier) { - val noteState by baseNote.live().metadata.observeAsState() - val author by remember(noteState) { - derivedStateOf { - noteState?.note?.author - } - } +fun ObserveDisplayNip05Status( + baseNote: Note, + columnModifier: Modifier = Modifier, + accountViewModel: AccountViewModel, + nav: (String) -> Unit +) { + val author by baseNote.live().authorChanges.observeAsState() author?.let { - ObserveDisplayNip05Status(it, columnModifier) + ObserveDisplayNip05Status(it, columnModifier, accountViewModel, nav) } } @Composable -fun ObserveDisplayNip05Status(baseUser: User, columnModifier: Modifier = Modifier) { - val nip05 by baseUser.live().metadata.map { - it.user.nip05() - }.observeAsState(baseUser.nip05()) +fun ObserveDisplayNip05Status( + baseUser: User, + columnModifier: Modifier = Modifier, + accountViewModel: AccountViewModel, + nav: (String) -> Unit +) { + val nip05 by baseUser.live().nip05Changes.observeAsState(baseUser.nip05()) - Crossfade(targetState = nip05, modifier = columnModifier) { - if (it != null) { - DisplayNIP05Line(it, baseUser, columnModifier) - } else { - Text( - text = baseUser.pubkeyDisplayHex(), - color = MaterialTheme.colors.placeholderText, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = columnModifier - ) + LoadStatuses(baseUser) { statuses -> + Crossfade(targetState = nip05, modifier = columnModifier, label = "ObserveDisplayNip05StatusCrossfade") { + VerifyAndDisplayNIP05OrStatusLine(it, statuses, baseUser, columnModifier, accountViewModel, nav) } } } @Composable -private fun DisplayNIP05Line(nip05: String, baseUser: User, columnModifier: Modifier = Modifier) { +private fun VerifyAndDisplayNIP05OrStatusLine( + nip05: String?, + statuses: ImmutableList, + baseUser: User, + columnModifier: Modifier = Modifier, + accountViewModel: AccountViewModel, + nav: (String) -> Unit +) { Column(modifier = columnModifier) { - val nip05Verified = nip05VerificationAsAState(baseUser.info!!, baseUser.pubkeyHex) - Crossfade(targetState = nip05Verified) { - Row(verticalAlignment = Alignment.CenterVertically) { - DisplayNIP05(nip05, it) + Row(verticalAlignment = Alignment.CenterVertically) { + if (nip05 != null) { + val nip05Verified = nip05VerificationAsAState(baseUser.info!!, baseUser.pubkeyHex) + + if (nip05Verified.value != true) { + DisplayNIP05(nip05, nip05Verified) + } else if (!statuses.isEmpty()) { + RotateStatuses(statuses, accountViewModel, nav) + } else { + DisplayNIP05(nip05, nip05Verified) + } + } else { + if (!statuses.isEmpty()) { + RotateStatuses(statuses, accountViewModel, nav) + } else { + DisplayUsersNpub(baseUser.pubkeyDisplayHex()) + } + } + } + } +} + +@Composable +fun RotateStatuses( + statuses: ImmutableList, + accountViewModel: AccountViewModel, + nav: (String) -> Unit +) { + var indexToDisplay by remember { + mutableIntStateOf(0) + } + + DisplayStatus(statuses[indexToDisplay], accountViewModel, nav) + + if (statuses.size > 1) { + LaunchedEffect(Unit) { + while (true) { + delay(10.seconds) + indexToDisplay = ((indexToDisplay + 1) % (statuses.size + 1)) + } + } + } +} + +@Composable +fun DisplayUsersNpub(npub: String) { + Text( + text = npub, + fontSize = 14.sp, + color = MaterialTheme.colors.placeholderText, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) +} + +@Composable +fun DisplayStatus( + addressableNote: AddressableNote, + accountViewModel: AccountViewModel, + nav: (String) -> Unit +) { + val noteState by addressableNote.live().metadata.observeAsState() + + val content = remember(noteState) { addressableNote.event?.content() ?: "" } + val type = remember(noteState) { + (addressableNote.event as? AddressableEvent)?.dTag() ?: "" + } + val url = remember(noteState) { + addressableNote.event?.firstTaggedUrl()?.ifBlank { null } + } + val nostrATag = remember(noteState) { + addressableNote.event?.firstTaggedAddress() + } + val nostrHexID = remember(noteState) { + addressableNote.event?.firstTaggedEvent()?.ifBlank { null } + } + + when (type) { + "music" -> Icon( + painter = painterResource(id = R.drawable.tunestr), + null, + modifier = Size18Modifier, + tint = MaterialTheme.colors.placeholderText + ) + else -> {} + } + + Text( + text = content, + fontSize = Font14SP, + color = MaterialTheme.colors.placeholderText, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + if (url != null) { + val uri = LocalUriHandler.current + IconButton( + modifier = Size15Modifier, + onClick = { runCatching { uri.openUri(url.trim()) } } + ) { + Icon( + imageVector = Icons.Default.OpenInNew, + null, + modifier = Size15Modifier, + tint = MaterialTheme.colors.lessImportantLink + ) + } + } else if (nostrATag != null) { + LoadAddressableNote(nostrATag) { note -> + if (note != null) { + IconButton( + modifier = Size15Modifier, + onClick = { + routeFor( + note, + accountViewModel.userProfile() + )?.let { nav(it) } + } + ) { + Icon( + imageVector = Icons.Default.OpenInNew, + null, + modifier = Size15Modifier, + tint = MaterialTheme.colors.lessImportantLink + ) + } + } + } + } else if (nostrHexID != null) { + LoadNote(baseNoteHex = nostrHexID) { + if (it != null) { + IconButton( + modifier = Size15Modifier, + onClick = { + routeFor( + it, + accountViewModel.userProfile() + )?.let { nav(it) } + } + ) { + Icon( + imageVector = Icons.Default.OpenInNew, + null, + modifier = Size15Modifier, + tint = MaterialTheme.colors.lessImportantLink + ) + } } } } @@ -157,6 +323,7 @@ private fun DisplayNIP05( if (user != "_") { Text( text = remember(nip05) { AnnotatedString(user) }, + fontSize = Font14SP, color = MaterialTheme.colors.nip05, maxLines = 1, overflow = TextOverflow.Ellipsis @@ -168,7 +335,7 @@ private fun DisplayNIP05( ClickableText( text = remember(nip05) { AnnotatedString(domain) }, onClick = { runCatching { uri.openUri("https://$domain") } }, - style = LocalTextStyle.current.copy(color = MaterialTheme.colors.nip05), + style = LocalTextStyle.current.copy(color = MaterialTheme.colors.nip05, fontSize = Font14SP), maxLines = 1, overflow = TextOverflow.Visible ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index aca280ee8..c5e2971a5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -124,7 +124,6 @@ import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.Font14SP import com.vitorpamplona.amethyst.ui.theme.HalfPadding import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding -import com.vitorpamplona.amethyst.ui.theme.HalfVertSpacer import com.vitorpamplona.amethyst.ui.theme.HeaderPictureModifier import com.vitorpamplona.amethyst.ui.theme.QuoteBorder import com.vitorpamplona.amethyst.ui.theme.Size10dp @@ -224,24 +223,10 @@ fun NoteCompose( accountViewModel: AccountViewModel, nav: (String) -> Unit ) { - val isBlank by baseNote.live().metadata.map { - it.note.event == null - }.distinctUntilChanged().observeAsState(baseNote.event == null) + val hasEvent by baseNote.live().hasEvent.observeAsState(baseNote.event != null) - Crossfade(targetState = isBlank) { + Crossfade(targetState = hasEvent) { if (it) { - LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup -> - BlankNote( - remember { - modifier.combinedClickable( - onClick = { }, - onLongClick = showPopup - ) - }, - isBoostedNote || isQuotedNote - ) - } - } else { CheckHiddenNoteCompose( note = baseNote, routeForLastRead = routeForLastRead, @@ -256,6 +241,18 @@ fun NoteCompose( accountViewModel = accountViewModel, nav = nav ) + } else { + LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup -> + BlankNote( + remember { + modifier.combinedClickable( + onClick = { }, + onLongClick = showPopup + ) + }, + isBoostedNote || isQuotedNote + ) + } } } } @@ -465,45 +462,86 @@ fun NormalNote( accountViewModel: AccountViewModel, nav: (String) -> Unit ) { - when (baseNote.event) { - is ChannelCreateEvent, is ChannelMetadataEvent -> ChannelHeader( - channelNote = baseNote, - showVideo = !makeItShort, - showBottomDiviser = true, - sendToChannel = true, - accountViewModel = accountViewModel, - nav = nav - ) - is CommunityDefinitionEvent -> (baseNote as? AddressableNote)?.let { - CommunityHeader( - baseNote = it, + if (isQuotedNote || isBoostedNote) { + when (baseNote.event) { + is ChannelCreateEvent, is ChannelMetadataEvent -> ChannelHeader( + channelNote = baseNote, + showVideo = !makeItShort, showBottomDiviser = true, - sendToCommunity = true, + sendToChannel = true, accountViewModel = accountViewModel, nav = nav ) - } - is BadgeDefinitionEvent -> BadgeDisplay(baseNote = baseNote) - is FileHeaderEvent -> FileHeaderDisplay(baseNote, isQuotedNote, accountViewModel) - is FileStorageHeaderEvent -> FileStorageHeaderDisplay(baseNote, isQuotedNote, accountViewModel) - else -> - LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup -> - CheckNewAndRenderNote( - baseNote, - routeForLastRead, - modifier, - isBoostedNote, - isQuotedNote, - unPackReply, - makeItShort, - addMarginTop, - canPreview, - parentBackgroundColor, - accountViewModel, - showPopup, - nav + is CommunityDefinitionEvent -> (baseNote as? AddressableNote)?.let { + CommunityHeader( + baseNote = it, + showBottomDiviser = true, + sendToCommunity = true, + accountViewModel = accountViewModel, + nav = nav ) } + is BadgeDefinitionEvent -> BadgeDisplay(baseNote = baseNote) + else -> + LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup -> + CheckNewAndRenderNote( + baseNote, + routeForLastRead, + modifier, + isBoostedNote, + isQuotedNote, + unPackReply, + makeItShort, + addMarginTop, + canPreview, + parentBackgroundColor, + accountViewModel, + showPopup, + nav + ) + } + } + } else { + when (baseNote.event) { + is ChannelCreateEvent, is ChannelMetadataEvent -> ChannelHeader( + channelNote = baseNote, + showVideo = !makeItShort, + showBottomDiviser = true, + sendToChannel = true, + accountViewModel = accountViewModel, + nav = nav + ) + is CommunityDefinitionEvent -> (baseNote as? AddressableNote)?.let { + CommunityHeader( + baseNote = it, + showBottomDiviser = true, + sendToCommunity = true, + accountViewModel = accountViewModel, + nav = nav + ) + } + is BadgeDefinitionEvent -> BadgeDisplay(baseNote = baseNote) + is FileHeaderEvent -> FileHeaderDisplay(baseNote, false, accountViewModel) + is FileStorageHeaderEvent -> FileStorageHeaderDisplay(baseNote, false, accountViewModel) + else -> + LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup -> + CheckNewAndRenderNote( + baseNote, + routeForLastRead, + modifier, + isBoostedNote, + isQuotedNote, + unPackReply, + makeItShort, + addMarginTop, + canPreview, + parentBackgroundColor, + accountViewModel, + showPopup, + nav + ) + } + } } } @@ -1014,7 +1052,7 @@ private fun NoteBody( ) } - Spacer(modifier = HalfVertSpacer) + Spacer(modifier = Modifier.height(3.dp)) if (!makeItShort) { ReplyRow( @@ -1144,6 +1182,14 @@ private fun RenderNoteRow( ) } + is FileHeaderEvent -> { + FileHeaderDisplay(baseNote, true, accountViewModel) + } + + is FileStorageHeaderEvent -> { + FileStorageHeaderDisplay(baseNote, true, accountViewModel) + } + is CommunityPostApprovalEvent -> { RenderPostApproval( baseNote, @@ -2441,7 +2487,7 @@ fun SecondUserInfoRow( val noteAuthor = remember { note.author } ?: return Row(verticalAlignment = CenterVertically, modifier = UserNameMaxRowHeight) { - ObserveDisplayNip05Status(noteAuthor, remember { Modifier.weight(1f) }) + ObserveDisplayNip05Status(noteAuthor, remember { Modifier.weight(1f) }, accountViewModel, nav) val geo = remember { noteEvent.getGeoHash() } if (geo != null) { @@ -2460,6 +2506,30 @@ fun SecondUserInfoRow( } } +@Composable +fun LoadStatuses( + user: User, + content: @Composable (ImmutableList) -> Unit +) { + var statuses: ImmutableList by remember { + mutableStateOf(persistentListOf()) + } + + val userStatus by user.live().statuses.observeAsState() + + LaunchedEffect(key1 = userStatus) { + launch(Dispatchers.IO) { + val myUser = userStatus?.user ?: return@launch + val newStatuses = LocalCache.findStatusesForUser(myUser) + if (!equalImmutableLists(statuses, newStatuses)) { + statuses = newStatuses + } + } + } + + content(statuses) +} + @Composable fun DisplayLocation(geohash: String, nav: (String) -> Unit) { val context = LocalContext.current @@ -3199,7 +3269,7 @@ private fun RenderBadge( } @Composable -fun FileHeaderDisplay(note: Note, isQuotedNote: Boolean, accountViewModel: AccountViewModel) { +fun FileHeaderDisplay(note: Note, roundedCorner: Boolean, accountViewModel: AccountViewModel) { val event = (note.event as? FileHeaderEvent) ?: return val fullUrl = event.url() ?: return @@ -3235,18 +3305,18 @@ fun FileHeaderDisplay(note: Note, isQuotedNote: Boolean, accountViewModel: Accou } SensitivityWarning(note = note, accountViewModel = accountViewModel) { - ZoomableContentView(content = content, roundedCorner = isQuotedNote, accountViewModel = accountViewModel) + ZoomableContentView(content = content, roundedCorner = roundedCorner, accountViewModel = accountViewModel) } } @Composable -fun FileStorageHeaderDisplay(baseNote: Note, isQuotedNote: Boolean, accountViewModel: AccountViewModel) { +fun FileStorageHeaderDisplay(baseNote: Note, roundedCorner: Boolean, accountViewModel: AccountViewModel) { val eventHeader = (baseNote.event as? FileStorageHeaderEvent) ?: return val dataEventId = eventHeader.dataEventId() ?: return LoadNote(baseNoteHex = dataEventId) { contentNote -> if (contentNote != null) { - ObserverAndRenderNIP95(baseNote, contentNote, isQuotedNote, accountViewModel) + ObserverAndRenderNIP95(baseNote, contentNote, roundedCorner, accountViewModel) } } } @@ -3255,7 +3325,7 @@ fun FileStorageHeaderDisplay(baseNote: Note, isQuotedNote: Boolean, accountViewM private fun ObserverAndRenderNIP95( header: Note, content: Note, - isQuotedNote: Boolean, + roundedCorner: Boolean, accountViewModel: AccountViewModel ) { val eventHeader = (header.event as? FileStorageHeaderEvent) ?: return @@ -3302,7 +3372,7 @@ private fun ObserverAndRenderNIP95( Crossfade(targetState = content) { if (it != null) { SensitivityWarning(note = header, accountViewModel = accountViewModel) { - ZoomableContentView(content = it, roundedCorner = isQuotedNote, accountViewModel = accountViewModel) + ZoomableContentView(content = it, roundedCorner = roundedCorner, accountViewModel = accountViewModel) } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 47f5ab187..c62083b6d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -419,14 +419,7 @@ private fun ReactionDetailGallery( val defaultBackgroundColor = MaterialTheme.colors.background val backgroundColor = remember { mutableStateOf(defaultBackgroundColor) } - val hasReactions by baseNote.live().zaps.combineWith( - baseNote.live().boosts, - baseNote.live().reactions - ) { zapState, boostState, reactionState -> - zapState?.note?.zaps?.isNotEmpty() ?: false || - boostState?.note?.boosts?.isNotEmpty() ?: false || - reactionState?.note?.reactions?.isNotEmpty() ?: false - }.distinctUntilChanged().observeAsState( + val hasReactions by baseNote.live().hasReactions.observeAsState( baseNote.zaps.isNotEmpty() || baseNote.boosts.isNotEmpty() || baseNote.reactions.isNotEmpty() ) @@ -603,9 +596,7 @@ fun ReplyReaction( @Composable fun ReplyCounter(baseNote: Note, textColor: Color) { - val repliesState by baseNote.live().replies.map { - it.note.replies.size - }.observeAsState(baseNote.replies.size) + val repliesState by baseNote.live().replyCount.observeAsState(baseNote.replies.size) SlidingAnimationCount(repliesState, textColor) } @@ -775,9 +766,7 @@ fun BoostIcon(baseNote: Note, iconSize: Dp = Size20dp, grayTint: Color, accountV @Composable fun BoostText(baseNote: Note, grayTint: Color) { - val boostState by baseNote.live().boosts.map { - it.note.boosts.size - }.distinctUntilChanged().observeAsState(baseNote.boosts.size) + val boostState by baseNote.live().boostCount.observeAsState(baseNote.boosts.size) SlidingAnimationCount(boostState, grayTint) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt index 864313a2f..a1296f4f1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt @@ -155,7 +155,7 @@ fun UpdateReactionTypeDialog( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - CloseButton(onCancel = { + CloseButton(onPress = { postViewModel.cancel() onClose() }) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt index 284d69ee1..e988207a4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt @@ -250,7 +250,7 @@ fun UpdateZapAmountDialog(onClose: () -> Unit, nip47uri: String? = null, account horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - CloseButton(onCancel = { + CloseButton(onPress = { postViewModel.cancel() onClose() }) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserReactionsRow.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserReactionsRow.kt index 8ae51ed24..45418fadd 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserReactionsRow.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserReactionsRow.kt @@ -57,6 +57,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import java.math.BigDecimal @@ -187,10 +188,10 @@ class UserReactionsViewModel(val account: Account) : ViewModel() { private var takenIntoAccount = setOf() private val sdf = DateTimeFormatter.ofPattern("yyyy-MM-dd") // SimpleDateFormat() - val todaysReplyCount = _replies.map { showCount(it[today()]) } - val todaysBoostCount = _boosts.map { showCount(it[today()]) } - val todaysReactionCount = _reactions.map { showCount(it[today()]) } - val todaysZapAmount = _zaps.map { showAmountAxis(it[today()]) } + val todaysReplyCount = _replies.map { showCount(it[today()]) }.distinctUntilChanged() + val todaysBoostCount = _boosts.map { showCount(it[today()]) }.distinctUntilChanged() + val todaysReactionCount = _reactions.map { showCount(it[today()]) }.distinctUntilChanged() + val todaysZapAmount = _zaps.map { showAmountAxis(it[today()]) }.distinctUntilChanged() fun formatDate(createAt: Long): String { return sdf.format( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt index 309bc8d15..80cbd63da 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt @@ -94,7 +94,7 @@ fun ZapCustomDialog(onClose: () -> Unit, accountViewModel: AccountViewModel, bas horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - CloseButton(onCancel = { + CloseButton(onPress = { postViewModel.cancel() onClose() }) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/ShowQRDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/ShowQRDialog.kt index b284489c8..a898e9633 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/ShowQRDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/ShowQRDialog.kt @@ -61,7 +61,7 @@ fun ShowQRDialog(user: User, onScan: (String) -> Unit, onClose: () -> Unit) { horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - CloseButton(onCancel = onClose) + CloseButton(onPress = onClose) } Column( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RelayPoolViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RelayPoolViewModel.kt index e9f91be1c..a57974b6c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RelayPoolViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RelayPoolViewModel.kt @@ -16,5 +16,5 @@ class RelayPoolViewModel : ViewModel() { val isConnected = RelayPool.live.map { it.relays.connectedRelays() > 0 - } + }.distinctUntilChanged() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt index 580c14d5b..a6b38f399 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt @@ -328,7 +328,7 @@ fun NoteMaster( } Row(verticalAlignment = Alignment.CenterVertically) { - ObserveDisplayNip05Status(baseNote, remember { Modifier.weight(1f) }) + ObserveDisplayNip05Status(baseNote, remember { Modifier.weight(1f) }, accountViewModel, nav) val geo = remember { noteEvent.getGeoHash() } if (geo != null) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountBackupDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountBackupDialog.kt index 36d624f79..05bde8aa8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountBackupDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountBackupDialog.kt @@ -70,7 +70,7 @@ fun AccountBackupDialog(account: Account, onClose: () -> Unit) { horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - CloseButton(onCancel = onClose) + CloseButton(onPress = onClose) } Column( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index d6f0901d5..169920c09 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -14,6 +14,7 @@ import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AccountState +import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.ConnectivityType import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User @@ -384,6 +385,14 @@ class AccountViewModel(val account: Account) : ViewModel() { } } + fun createStatus(newStatus: String) { + account.createStatus(newStatus) + } + + fun updateStatus(it: AddressableNote, newStatus: String) { + account.updateStatus(it, newStatus) + } + class Factory(val account: Account) : ViewModelProvider.Factory { override fun create(modelClass: Class): AccountViewModel { return AccountViewModel(account) as AccountViewModel diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt index 382975449..36f662f29 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt @@ -557,7 +557,7 @@ fun ChatroomHeader( Column(modifier = Modifier.padding(start = 10.dp)) { UsernameDisplay(baseUser) - ObserveDisplayNip05Status(baseUser) + ObserveDisplayNip05Status(baseUser, accountViewModel = accountViewModel, nav = nav) } } } @@ -669,7 +669,7 @@ fun NewSubjectView(onClose: () -> Unit, accountViewModel: AccountViewModel, room horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - CloseButton(onCancel = { + CloseButton(onPress = { onClose() }) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ConnectOrbotDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ConnectOrbotDialog.kt index 3a6d4def2..ceee975b6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ConnectOrbotDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ConnectOrbotDialog.kt @@ -56,7 +56,7 @@ fun ConnectOrbotDialog(onClose: () -> Unit, onPost: () -> Unit, portNumber: Muta verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth() ) { - CloseButton(onCancel = { + CloseButton(onPress = { onClose() }) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt index 9ab694bfe..1ce0efd78 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt @@ -60,6 +60,7 @@ import com.vitorpamplona.amethyst.ui.note.ReplyReaction import com.vitorpamplona.amethyst.ui.note.ViewCountReaction import com.vitorpamplona.amethyst.ui.note.WatchForReports import com.vitorpamplona.amethyst.ui.note.ZapReaction +import com.vitorpamplona.amethyst.ui.note.routeFor import com.vitorpamplona.amethyst.ui.screen.FeedEmpty import com.vitorpamplona.amethyst.ui.screen.FeedError import com.vitorpamplona.amethyst.ui.screen.FeedState @@ -373,7 +374,9 @@ private fun RenderAuthorInformation( Row(verticalAlignment = Alignment.CenterVertically) { ObserveDisplayNip05Status( remember { note.author!! }, - remember { Modifier.weight(1f) } + remember { Modifier.weight(1f) }, + accountViewModel, + nav = nav ) } Row( @@ -451,8 +454,14 @@ fun ReactionsColumn(baseNote: Note, accountViewModel: AccountViewModel, nav: (St Spacer(modifier = Modifier.height(8.dp)) Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(bottom = 75.dp, end = 20.dp)) { + val scope = rememberCoroutineScope() ReplyReaction(baseNote, grayTint = MaterialTheme.colors.onBackground, accountViewModel, iconSize = 40.dp) { - wantsToReplyTo = baseNote + scope.launch { + routeFor( + baseNote, + accountViewModel.userProfile() + )?.let { nav(it) } + } } BoostReaction(baseNote, grayTint = MaterialTheme.colors.onBackground, accountViewModel, iconSize = 40.dp) { wantsToQuote = baseNote diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt index e61c0de51..bf7074b88 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt @@ -126,7 +126,7 @@ val ChatHeadlineBorders = Modifier.padding(start = 12.dp, end = 12.dp, top = 10. val VolumeBottomIconSize = Modifier.size(70.dp).padding(10.dp) val PinBottomIconSize = Modifier.size(70.dp).padding(10.dp) -val NIP05IconSize = Modifier.size(14.dp).padding(top = 1.dp, start = 1.dp, end = 1.dp) +val NIP05IconSize = Modifier.size(13.dp).padding(top = 1.dp, start = 1.dp, end = 1.dp) val EditFieldModifier = Modifier .padding(start = 10.dp, end = 10.dp, bottom = 10.dp, top = 5.dp) diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml new file mode 100644 index 000000000..712d6c18a --- /dev/null +++ b/app/src/main/res/values-th/strings.xml @@ -0,0 +1,552 @@ + + Amethyst + Amethyst Debug + สแกน Qr Code + โชว์ QR + รูปโปรไฟล์ + แสกน QR + แสดง + โพสต์ถูกรายงานว่าไม่เหมาะสมโดย + ไม่พบโพสต์นี้ + รูปของ channel + ไม่พบ event ที่อ้างอิง + ไม่สามารถเข้ารหัสข้อความได้ + รูปภาพกลุ่ม + เนื้อหาที่มีความรุนแรง + สแปม + เลียนแบบ + พฤติกรรมที่ผิดกฎหมาย + ไม่ทราบ + รีเลย์ไอคอน + ไม่ทราบผู้เขียน + คัดลอกข่้อความ + คัดลอก ID ผู้เขียน + คัดลอก ID โน้ต + เพยแพร่ + ส่งคำขอให้ลบ + บล๊อก / รายงาน + + รายงาน: สแปม / หลอกลวง + รายงาน: การเลียนแบบ + รายงาน: เนื้อหารุนแรง + รายงาน: พฤติกรรมที่ผิดกฎหมาย + เข้าสู่ระบบด้วย private key เพื่อตอบกลับ + เข้าสู่ระบบด้วย private key เพื่อบูสโพสต์ + เข้าสู่ระบบด้วย private key เพื่อถูกใจโพสต์ + ไม่มีการตั้งค่าจำนวนเพื่อ zap กดค้างเพื่อตั้งค่า + เข้าสู่ระบบด้วย private key เพื่อส่ง zaps + เข้าสู่ระบบด้วย private key เพื่อติดตาม + เข้าสู่ระบบด้วย private key เพื่อเลิกติดตาม + Zaps + ยอดเข้าชม + Boost + boosted + โควท + ตั้งค่าจำนวนในหน่วย sat + เพิ่ม + "ตอบกลับถึง " + " และ " + " ในช่อง " + หน้าปกโปรไฟล์ + การชำรพเงินเสร็จสมบูรณ์ + เกิดข้อผิดพลาดในการวิเคราะห์ข้อความแสดงข้อผิดพลาด + " กำลังติดตาม" + " ผู้ติดตาม" + โปรไฟล์ + ตัวกรอกความปลอดภัย + ออกจากระบบ + แสดงเพิ่มเติม + Lightning Invoice + จ่าย + Lightning Tips + โน้ตถึงผู้รับ + ขอบคุณมาก! + จำนวนในหน่วย sat + ส่ง Sats + "ข้อผิดพลาดในการวิเคราะห์ตัวอย่างสําหรับ %1$s : %2$s" + "รูปตัวอย่างสำหรับ %1$s" + Channel ใหม่ + ชื่อ Channel + กลุ่มที่ยอดเยี่ยมของฉัน! + Picture Url + รายละเอียด + "เกี่ยวกับเรา.. " + คุณคิดอะไรอยู่ ? + โพสต์ + บันทึก + สร้าง + ยกเลิก + ไม่สามารถโหลดรูปภาพได้ + Relay Address + โพสต์ + Bytes + ข้อผิดพลาด + ฟีดเริ่มต้น + ฟีดข้อความส่วนตัว + ฟีดข้อความ + ฟีดโลก + ฟีดค้นหา + เพิ่มรีเลย์ + ชื่อที่แสดง + ชื่อที่แสดงของฉัน + ชื่อผู้ใช้ + ชื่อผู้ใช้ของฉัน + เกี่ยวกับฉัน + Avatar URL + Banner URL + Website URL + LN Address + LN URL (หมดอายุ) + บันทึกรูปภาพลงแกลเลอรี + เกิดข้อผิดพลาดในการบันทึกรูปภาพ + อัพโหลดรูปภาพ + กำลังอัพโหลด + ผู้ใช้นี้ไม่ได้ตั้งค่า lightning address เพื่อรับ sats + "ตอบกลับที่นี่.. " + คัดลอกโน้ต ID ลงคลิปบอร์ดเพื่อแชร์ไปยัง Nostr + คัดลอก Channel ID (โน้ต) ลงคลิปบอร์ด + แก้ไข Channel Metadata + เข้าร่วม + รู้ + ส่งคำขอใหม่ + ผู้ใช้ที่ถูกบล็อก + เธรดใหม่ + การสนทนา + โน้ต + ตอบกลับ + "ติดตาม" + "รายงาน" + ตัวเลือกเพิ่มเติม + " รีเลย์" + เว็บไซต์ + Lightning Address + คัดลอก Nsec ID (รหัสผ่านของคุณ) ลงคลิปบอร์ดเพื่อสำรองข้อมูล + คัดลอก Secret Key ลงคลิปบอร์ด + คัดลอก public key ลงคลิปบอร์ดเพื่อแชร์ + คัดลอก Public Key (NPub) ลงคลิปบอร์ด + ส่งข้อความส่วนตัว + แก้ไข the User\'s Metadata + ติดตาม + ติดตามกลับ + เลิกบล๊อก + คัดลอก ID ผู้ใช้ + เลิกบล๊อกผู้ใช้ + "npub, ชื่อผู้ใช้, ข้อความ" + เคลียร์ + App Logo + nsec.. or npub.. + แสดงรหัสผ่าน + ซ่อนรหัสผ่าน + key ไม่ถูกต้อง + "ฉันยอมรับ " + เงื่อนไขการใช้งาน + จําเป็นต้องยอมรับข้อกําหนด + ต้องใช้ key + เข้าสู่ระบบ + สร้าง key ใหม่ + กำลังดาวน์โหลดฟีด + "ไม่สามารถโหลดการตอบกลับ: " + ลองอีกครั้ง + ฟีดนี้ว่างเปล่า + รีเฟรช + สร้าง + พร้อมคําอธิบายของ + และ รูปภาพ + เปลี่ยนชื่อ chat เป็น + คําอธิบายของ + และ รูปภาพของ + ออก + เลิกติดตาม + สร้าง Channel + "ข้อมูลของ Channel เปลี่ยนเป็น" + แชทสาธรณะ + ได้รับโพสต์แล้ว + ลบ + sats + อัตโนมัติ + แปลจาก + ถึง + แสดงใน %1$s ก่อน + แปลเป็น %1$s เสมอ + อย่าแปลจาก %1$s + Nostr Address + LNURL... + ไม่เคย + ตอนนี้ + h + m + d + ภาพเปลือย/สื่อลามก + คําหยาบคาย / คําพูดที่แสดงความเกลียดชัง + รายงาน: คําพูดที่แสดงความเกลียดชัง + รายงาน: ภาพเปลือย/สื่อลามก + อื่น ๆ + ทำเครื่องหมายอ่านแล้วในแชทที่รู้จักทั้งหมด + ทำเครื่องหมายว่าอ่านแล้วทั้งหมดสำหรับข้อความใหม่ + ทพเครื่องหมายว่าอ่านแล้วทั้งหมด + สพรองข้อมูล Keys + + ## เคล็ดลับการสํารองข้อมูลและความปลอดภัยที่สําคัญ + \n\n บัญชีของคุณมีความปลอดภัยด้วยรหัสลับ key คือสตริงสุ่มยาวที่ขึ้นต้นด้วย **nsec1** ทุกคนที่มีสิทธิ์เข้าถึงรหัสลับของคุณสามารถเผยแพร่เนื้อหาโดยใช้บัญชีของคุณได้ + \n\n- **อย่า** ใส่รหัสลับของคุณในเว็บไซต์หรือซอฟต์แวร์ที่คุณไม่เชื่อถือ + \n- นักพัฒนา Amethyst จะ **ไม่เคย** ขอรหัสลับของคุณ + \n- **เก็บ** สําเนาสํารองคีย์ลับของคุณไว้อย่างปลอดภัยสําหรับการกู้คืนบัญชี เราขอแนะนําให้ใช้ password manager. + + คัดลอก Secret key (nsec) ลงคลิปบอร์ด + คัดลอก secret key ของฉัน + เกิดข้อผิดพลาดในการตรวจสอบ + ข้อผิดพลาด + "สร้างโดย %1$s" + "ภาพเหรียญตราสําหรับ %1$s" + คุณได้รับเหรียญตราใหม่ + เหรียญตราถูกมอบให้กับ + คัดลอกข่้อความในโน้ตลงคลิปบอร์ด + คัดลอก @npub ของผู้เขียนลงคลิปบอร์ด + คัดลอกโน้ต ID (@note1)ลงคลิปบอร์ด + เลือกข้อความ + Github Gist w/ Proof + Telegram + Mastodon Post ID w/ Proof + Twitter Status w/ Proof + https://gist.github.com/<user>/<gist> + https://t.me/<proof post> + https://<server>/<user>/<proof post> + https://twitter.com/<user>/status/<proof post> + "<ไม่สามารถถอดรหัสข้อความส่วนตัวได้>\n\nคุณถูกอ้างถึงในการสนทนาส่วนตัว/เข้ารหัสระหว่าง %1$s และ %2$s." + เพิ่มบัญชีใหม่ + บัญชี + เลือกบัญชี + เพิ่มบัญชีใหม่ + บัญชีที่ใช้งานอยู่ + มี private key + อ่านเท่านั้น, ไม่มี private key + ย้อนกลับ + เลือก + แชร์ Browser Link + แชร์ + ID ผู้เขียน + ID โน้ต + คัดลอกข้อความ + ลบ + เลิกติดตาม + ติดตาม + ส่งคำขอให้ลบ + Amethyst จะขอให้ลบโน้ตของคุณออกจากรีเลย์ที่คุณเชื่อมต่ออยู่ ไม่มีการรับประกันว่าโน้ตของคุณจะถูกลบออกอย่างถาวรจากรีเลย์เหล่านั้น หรือ จากรีเลย์อื่น ๆ ที่อาจเก็บไว้ + บล๊อก + ลบ + บล๊อก + รายงาน + ลบ + ไม่แสดงข้อความนี้อีก + สแปม หรือ หลอกลวง + คําหยาบคายหรือการแสดงความเกลียดชัง + การแอบอ้างบุคคลอื่นที่เป็นอันตราย + เนื้อหาภาพเปลือย + พฤติกรรมที่ผิดกฎหมาย + การบล็อกผู้ใช้จะซ่อนเนื้อหาของพวกเขาในแอปของคุณ โน้ตของคุณยังคงดูได้แบบสาธารณะรวมถึงคนที่คุณบล็อกด้วย ผู้ใช้ที่ถูกบล็อกจะแสดงรายการบนหน้าจอตัวกรองความปลอดภัย + + รายงานการล่วงละเมิด + การรายงานที่โพสต์ทั้งหมดจะปรากฏต่อสาธารณะ + ระบุข้อความเพิ่มเติมเกี่ยวกับรายงานของคุณ (ไม่บังคับ) + ข้อความเพิ่มเติม + เหตุผล + เลือกเหตุผล… + รายงานโพสต์ + บล๊อกและรายงาน + บล๊อก + + บุ๊คมาร์ค + บุ๊คมาร์คส่วนตัว + บุ๊คมาร์คสาธรณะ + เพิ่มไปยังบุ๊คมาร์คส่วนตัว + เพิ่มไปยังบุ๊คมาร์คสาธรณะ + ลบออกจากบุ๊คมาร์คส่วนตัว + ลบออกจากบุ๊คมาร์คสาธรณะ + + บริการเชื่อมต่อ wallet + อนุญาตให้ Nostr Secret จ่าย zaps โดยไม่ต้องออกจากแอป เพื่อรักษาความลับให้ปลอดภัยและใช้รีเลย์ส่วนตัวหากเป็นไปได้ + Wallet Connect Pubkey + Wallet Connect Relay + Wallet Connect Secret + แสดง secret key + nsec / hex private key + + จำนวนเงินที่นำเข้าในหน่วย sat + สร้างโพลล์ + ส่วนที่จำเป็น: + ผู้รับ Zap + คำอธิบายโพลล์… + ตัวเลือก %s + คำอธิบายตัวเลือกในโพลล์ + ตำอธิบายเพิ่มเติม: + Zap ขั้นต่ำ + Zap สูงสุด + ฉันทามติ + (0–100)% + ปิดหลังจาก + วัน + โพลปิดรับการลงคะแนนเพิ่มเติม + จำนวน Zap + อนุญาตให้โหวตได้เพียงครั้งเดียวต่อผู้ใช้หนึ่งคนสำหรับการสำรวจประเภทนี้ + + "กำลังมองหา Event %1$s" + + เพิ่มในข้อความสาธรณะ + เพิ่มในข้อความส่วนตัว + เพิ่มใน invoice message + + ขอบคุณสำหรับทุกงานของคุณ! + + สร้างและเพิ่ม + ผู้เขียนโพลล์ไม่สามารถลงคะแนนในโพล์ของตนเองได้ + #zappoll + + เนื้อหานี้เหมือนเดิมตั้งแต่โพสต์ + เนื้อหามีการเปลี่ยนแปลง ผู้เขียนอาจไม่เห็นหรืออนุมัติการเปลี่ยนแปลง + + เพิ่มรูปภาพ + เพิ่มวีดีโอ + เพิ่มเอกสาร + + เพิ่มไปยังข้อความ + คำอธิบายของเนื้อหา + เรือสีฟ้าในหาดทรายสีขาวยามพระอาทิตย์ตก + + ประเภทการ Zap + ตัวเลือกทั้งหมดสำหรับการ zap + + สาธรณะ + ทุกคนสามารถเห็นธุรกรรมและการข้อความ + + ส่วนตัว + ผู้ส่งและผู้รับสามารถเห็นธุรกรรมและข้อความ + + ไม่ระบุตัวตน + ไม่มีใครรู้ว่าใครเป็นคนทำธุรกรรม + + Non-Zap + ไม่มีการบันทึกบน Nostr, มีแค่ในระบบ Lightning + + + File Server + LnAddress or @User + + imgur.com - trusted + Imgur สามารถแก้ไขไฟล์ได้ + + nostrimg.com - trusted + NostrImg สามารถแก้ไขไฟล์ได้ + + nostr.build - trusted + Nostr.build สามารถแก้ไขไฟล์ได้ + + nostrfiles.dev - trusted + Nostrfiles.dev สามารถแก้ไขไฟล์ได้ + + nostrcheck.me - trusted + nostrcheck.me สามารถแก้ไขไฟล์ได้ + + + Verifiable Imgur (NIP-94) + ตวรจสอบว่า Imgur แก้ไขไฟล์ New NIP: ไคลเอนต์อื่น ๆ จะไม่เห็นมัน + + Verifiable NostrImg (NIP-94) + ตวรจสอบว่า NostrImg แก้ไขไฟล์ New NIP: ไคลเอนต์อื่น ๆ จะไม่เห็นมัน + + Verifiable Nostr.build (NIP-94) + ตวรจสอบว่า Nostr.build แก้ไขไฟล์ New NIP: ไคลเอนต์อื่น ๆ จะไม่เห็นมัน + + Verifiable Nostrfiles.dev (NIP-94) + ตวรจสอบว่า Nostrfiles.dev แก้ไขไฟล์ New NIP: ไคลเอนต์อื่น ๆ จะไม่เห็นมัน + + Verifiable Nostrcheck.me (NIP-94) + ตวรจสอบว่า Nostrcheck.me แก้ไขไฟล์ New NIP: ไคลเอนต์อื่น ๆ จะไม่เห็นมัน + + รีเลย์ของคุณ (NIP-95) + ไฟล์อยู่บนรีเลย์ของคุณ New NIP: โปรดตรวจสอบว่ามีการรองรับหรือไม่ + + ตั้งค่า Tor/Orbot + การตั้งค่าการเชื่อมต่อ Orbot ของคุณ + + ตัดการเชื่อมต่อจาก Orbot/Tor ของคุณ? + ข้อมูลของคุณจะถูกถ่ายโอนในเครือข่ายปกติทันที + ใช่ + ไม่ + + + รายการผู้ติดตาม + ติดตามทั้งหมด + Global + + ## เชื่อมต่อกัย Tor ด้วย Orbot + \n\n1. ติดตั้ง [Orbot](https://play.google.com/store/apps/details?id=org.torproject.android) + \n2. เริ่มต้นใช้งาน Orbot + \n3. ใน Orbot, ตรวจสอบe Socks port. ค่าเริ่มต้นเป็น 9050 + \n4. หากจําเป็นให้เปลี่ยนพอร์ตใน Orbot + \n5. กําหนดค่า Socks port บนหน้าจอนี้ + \n6. กดปุ่มเปิดใช้งานเพื่อใช้ Orbot เป็น proxy + + Orbot Socks Port + เลข port ไม่ถูกต้องr + ใช้ Orbot + ตัดการเชื่อมต่อจาก Tor/Orbot + + DefaultChannelID + New notification arrived + + PrivateMessagesID + ข้อความส่วนตัว + แจ้งเตือนฉันเมื่อข้อความส่วนตัวมาถึง + + ZapsID + ได้รับ Zaps + แจ้งเตือนเมื่อมีคน zaps + %1$s sats + จาก %1$s + ถึง %1$s + + การแจ้งเตือน: + + เข้าร่วมการสนทนา + ผู้ใช้ หรือ Group\'s ID + npub, nevent or hex + สร้าง + เข้าร่วม + + วันนี้ + + คําเตือนเนื้อหา + โพสต์นี้มีเนื้อหาที่ละเอียดอ่อนซึ่งบางคนอาจพบว่าไม่เหมาะสมหรือรบกวน + ซ่อนเนื้อหาที่ละเอียดอ่อนเสมอ + แสดงเนื้อหาที่ละเอียดอ่อนเสมอ + แสดงคำเตือนเนื้อหาเสมอ + + แนะนำ: + กรองสแปมจากคนแปลกหน้า + เตือนเมื่อมีการรายงานโพสต์จากผู้ติดตามของคุณ + + สัญลักษณ์ Reaction ใหม่ + ไม่มีการเลือกประเภทของ reaction กดค้างเพื่อเปลี่ยน + + Zapraiser + เพิ่มจํานวนเป้าหมายของ sats ที่จะเพิ่มสําหรับโพสต์นี้ การสนับสนุนไคลเอนต์อาจแสดงสิ่งนี้เป็นแถบความคืบหน้าเพื่อจูงใจการบริจาค + จำนวนเป้าหมายในหน่วย Sats + + Zapraiser at %1$s. %2$s sats เพื่อไปถึงเป้าหมาย + อ่านจาก Relay + เขียนบน Relay + เกิดข้อผิดพลาดในการรับข้อมูลจาก %1$s + เจ้าของ + Version + Software + ติดต่อ + รับรอง NIPs + ค่าใช้จ่ายสำหรับการใช้งาน + url การชำระเงิน + ข้อจำกัด + ประเทศ + ภาษา + Tags + นโยบายการโพสต์ + ความยาวข้อความ + การสมัครสมาชิก + ฟิลเตอร์ + ความยาวของรหัสการสมัครสมาชิก + คำนำหน้าขั้นต่ำ + event tags สูงสุด + ความยาวของเนื้อหา + PoW ขั้นต่ำ + Auth + การชำระเงิน + โทเคน Cashu + แลกคืน + ไม่ได้ตั้งค่า Lightning Address + คัดลอกโทเคนลงคลิปบอร์ด + + LIVE + OFFLINE + ENDED + SCHEDULED + + Livestream is Offline + Livestream Ended + การออกจากระบบจะลบข้อมูลทั้งหมดของคุณ ตรวจสอบให้แน่ใจว่าได้สํารองข้อมูล private key ไว้เพื่อหลีกเลี่ยงการสูญเสียบัญชีของคุณ คุณต้องการดําเนินการต่อหรือไม่? + Followed Tags + + รีเลย์ + + Live + ชุมชน + ช่องสนทนา + อนุมัติโพสต์ + + ชุมชนนี้ไม่มีคำอธิบายหรือกฏ พูดคุยกับเจ้าของเพื่อเพิ่มเติมสิ่งนี้ + ชุมชนนี้ไม่มีคำอธิบาย พูดคุยกับเจ้าของเพื่อเพิ่มเติมสิ่งนี้ + + เนื้อหาที่ละเอียดอ่อน + เพิ่มการแจ้งเตือนเนื้อหาที่ละเอียดอ่อนก่อนโชว์เนื้อหานี้ + การตั้งค่า + Aตลอดเวลา + Wifi เท่านั้น + ไม่ต้องแสดง + + ระบบ(ค่าพื้นฐาน) + สว่าง + มืด + การตั้งค่าแอปพลิเคชัน + ภาษา + ธีม + ภาพตัวอย่าง + การเล่นวิดีโอ + การแสดงตัวอย่าง URL + โหลดรูปภาพ + + Spammers + + ปิดการมองเห็น คลิกเพื่อปลดออก + เปิดการมองเห็นอยู่ คลิกเพื่อปิด + ค้นหาบันทึกการเข้าถึงแบบ local และ remote + + Nostr address ได้รับการยืนยัน + ไม่สามารถยืนยัน Nostr address ได้ + ตรวจสอบ Nostr address + เลือก/ไม่เลือก ทั้งหมด + ค่าเริ่มต้น + เลือกรีเลย์เพื่อดำเนินการต่อ + + ส่งต่อ Zaps ถึง: + ไคลเอนต์ที่สนับสนุนจะส่งต่อ zaps ไปยัง LNAddress หรือโปรไฟล์ผู้ใช้ด้านล่างแทนที่จะเป็นของคุณ + + เปิดเผยตําแหน่งที่ตั้ง + เพิ่ม Geohash ของตําแหน่งของคุณลงในโพสต์ สาธารณชนจะรู้ว่าคุณอยู่ภายใน 5 กม. (3 ไมล์) จากตําแหน่งปัจจุบัน + + เพิ่มคําเตือนเนื้อหาที่ละเอียดอ่อนก่อนแสดงเนื้อหาของคุณ สิ่งนี้เหมาะสําหรับเนื้อหา NSFW หรือเนื้อหาใด ๆ ที่บางคนอาจพบว่าไม่เหมาะสมหรือรบกวน + + ฟีเจอร์ใหม่ + การเปิดใช้งานโหมดนี้ต้องใช้ Amethyst เพื่อส่งข้อความ NIP-24 (GiftWrapped, Sealed Direct และ Group Messages) NIP-24 เป็นของใหม่และไคลเอ็นต์ส่วนใหญ่ยังไม่ได้รองรับ ตรวจสอบให้แน่ใจว่าเครื่องรับใช้ไคลเอ็นต์ที่รองรับ + เปิดใช้งาน + + สาธรณะ + ส่วนตัว + ถึง + ชื่อเรื่อง + หัวข้อการสนทนา + "@User1, @User2, @User3" + + ชมาชิกในกลุ่ม + คําอธิบายต่อสมาชิก + เปลี่ยนชื่อสําหรับเป้าหมายใหม่ + + สำหรับ App\'s Interface + ธีม: มืด, สว่าง หรือระบบ + โหลดรูปภาพและ GIFs โดยอัตโนมัติ + เล่นวีดีโอและ GIFs โดยอัตโนมัติ + แสดงจัวอย่าง URL + ควรโหลดรูปภาพเมื่อใด + + คัดลอก URL ลงคลิปบอร์ด + คัดลอก โน้ต ID ลงคลิปบอร์ดd + + สร้างโดย + กฏ + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ed8fbb0a6..2af5d61c4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -551,5 +551,8 @@ Created at Rules + Login with Amber + + Update your status diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml index 64d933c4f..b1ffdebbd 100644 --- a/app/src/main/res/xml/locales_config.xml +++ b/app/src/main/res/xml/locales_config.xml @@ -16,6 +16,7 @@ + diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt index 123bd2f70..7026382df 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt @@ -69,6 +69,16 @@ open class Event( override fun taggedUrls() = tags.filter { it.size > 1 && it[0] == "r" }.map { it[1] } + override fun firstTaggedUser() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.let { it[1] } + override fun firstTaggedEvent() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.let { it[1] } + override fun firstTaggedUrl() = tags.firstOrNull { it.size > 1 && it[0] == "r" }?.let { it[1] } + override fun firstTaggedAddress() = tags.firstOrNull { it.size > 1 && it[0] == "r" }?.let { + val aTagValue = it[1] + val relay = it.getOrNull(2) + + ATag.parse(aTagValue, relay) + } + override fun taggedEmojis() = tags.filter { it.size > 2 && it[0] == "emoji" }.map { EmojiUrl(it[1], it[2]) } override fun isSensitive() = tags.any { @@ -120,6 +130,14 @@ open class Event( return tags.any { it.size > 1 && it[0] == "a" && it[1].startsWith(kindStr) } } + override fun expiration() = try { + tags.firstOrNull { it.size > 1 && it[0] == "expiration" }?.get(1)?.toLongOrNull() + } catch (_: Exception) { + null + } + + override fun isExpired() = (expiration() ?: Long.MAX_VALUE) < TimeUtils.now() + override fun getTagOfAddressableKind(kind: Int): ATag? { val kindStr = kind.toString() val aTag = tags diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt index 0cdab7daa..1069f58c9 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt @@ -74,6 +74,7 @@ class EventFactory { RelaySetEvent.kind -> RelaySetEvent(id, pubKey, createdAt, tags, content, sig) ReportEvent.kind -> ReportEvent(id, pubKey, createdAt, tags, content, sig) RepostEvent.kind -> RepostEvent(id, pubKey, createdAt, tags, content, sig) + StatusEvent.kind -> StatusEvent(id, pubKey, createdAt, tags, content, sig) TextNoteEvent.kind -> TextNoteEvent(id, pubKey, createdAt, tags, content, sig) else -> Event(id, pubKey, createdAt, kind, tags, content, sig) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventInterface.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventInterface.kt index 2cc03a5a3..61ba9dbc6 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventInterface.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventInterface.kt @@ -49,6 +49,8 @@ interface EventInterface { fun isTaggedAddressableKind(kind: Int): Boolean fun getTagOfAddressableKind(kind: Int): ATag? + fun expiration(): Long? + fun hashtags(): List fun geohashes(): List @@ -66,6 +68,12 @@ interface EventInterface { fun taggedEvents(): List fun taggedUrls(): List + fun firstTaggedAddress(): ATag? + fun firstTaggedUser(): HexKey? + fun firstTaggedEvent(): HexKey? + fun firstTaggedUrl(): String? + fun taggedEmojis(): List fun matchTag1With(text: String): Boolean + fun isExpired(): Boolean } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/StatusEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/StatusEvent.kt new file mode 100644 index 000000000..299335c96 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/StatusEvent.kt @@ -0,0 +1,54 @@ +package com.vitorpamplona.quartz.events + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.utils.TimeUtils +import com.vitorpamplona.quartz.encoders.toHexKey +import com.vitorpamplona.quartz.crypto.CryptoUtils +import com.vitorpamplona.quartz.encoders.ATag +import com.vitorpamplona.quartz.encoders.HexKey + +@Immutable +class StatusEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: List>, + content: String, + sig: HexKey +) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig) { + + companion object { + const val kind = 30315 + + fun create( + msg: String, + type: String, + expiration: Long?, + privateKey: ByteArray, + createdAt: Long = TimeUtils.now() + ): StatusEvent { + val tags = mutableListOf>() + + tags.add(listOf("d", type)) + expiration?.let { tags.add(listOf("expiration", it.toString())) } + + val pubKey = CryptoUtils.pubkeyCreate(privateKey).toHexKey() + val id = generateId(pubKey, createdAt, kind, tags, msg) + val sig = CryptoUtils.sign(id, privateKey) + return StatusEvent(id.toHexKey(), pubKey, createdAt, tags, msg, sig.toHexKey()) + } + + fun update( + event: StatusEvent, + newStatus: String, + privateKey: ByteArray, + createdAt: Long = TimeUtils.now() + ): StatusEvent { + val tags = event.tags + val pubKey = event.pubKey() + val id = generateId(pubKey, createdAt, kind, tags, newStatus) + val sig = CryptoUtils.sign(id, privateKey) + return StatusEvent(id.toHexKey(), pubKey, createdAt, tags, newStatus, sig.toHexKey()) + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/utils/TimeUtils.kt b/quartz/src/main/java/com/vitorpamplona/quartz/utils/TimeUtils.kt index 140355dd6..e88af3ad4 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/utils/TimeUtils.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/TimeUtils.kt @@ -13,6 +13,7 @@ object TimeUtils { fun now() = System.currentTimeMillis() / 1000 fun fiveMinutesAgo() = now() - fiveMinutes fun oneHourAgo() = now() - oneHour + fun oneHourAhead() = now() + oneHour fun oneDayAgo() = now() - oneDay fun eightHoursAgo() = now() - eightHours fun oneWeekAgo() = now() - oneWeek