Compare commits

...
18 Commits
Author SHA1 Message Date
Vitor Pamplona bee5c1bfa6 v0.66.2 2023-07-07 12:29:22 -04:00
Vitor Pamplona c93a6dffc4 Creates a fallback for Android9 on OPPO, which doesn't seem able to parse the Japanese regex. 2023-07-07 12:27:57 -04:00
Vitor Pamplona 60dadc0088 BugFix for invalid URLs crashing the app. 2023-07-07 12:15:28 -04:00
Vitor Pamplona a26c7e3d97 Fixes caching of time display. 2023-07-07 09:45:21 -04:00
Vitor Pamplona 9e2b2cedd6 v0.66.1 2023-07-06 21:10:44 -04:00
Vitor Pamplona 636d59659d Activates reactions and zaps to the channel and community headers. 2023-07-06 21:04:07 -04:00
Vitor Pamplona 63e8391893 Allows notification to the channel headers 2023-07-06 21:03:51 -04:00
Vitor Pamplona 2f354e4b81 Adds EOSE treatment for the Discovery datasource 2023-07-06 21:03:36 -04:00
Vitor Pamplona b3a369d91b Removes top bar for Community and Hashtag posts 2023-07-06 21:03:05 -04:00
Vitor Pamplona 4406532994 Loads the last 50 messages per following chat. 2023-07-06 21:02:47 -04:00
Vitor Pamplona 8fd5176e22 Makes sure all services stop when the app pauses. 2023-07-06 21:02:18 -04:00
Vitor Pamplona e3396065c6 Reducing size of the discovery tab strings. 2023-07-06 17:38:55 -04:00
Vitor Pamplona 84600c016e Fixing for emoji families 2023-07-06 17:36:11 -04:00
Vitor Pamplona 64d96ab1d7 Fixes Emoji Joining issues. 2023-07-06 17:24:42 -04:00
Vitor Pamplona 6d6720a511 Adds reactions to Chats and Communities 2023-07-06 15:51:46 -04:00
Vitor Pamplona 17232a1909 Moves communities from /c/name to /n/name 2023-07-06 15:51:28 -04:00
Vitor Pamplona eb02049507 Turns parsing failures into warnings. 2023-07-06 15:51:14 -04:00
Vitor Pamplona ed7ad2f51b Adds communities to the readme 2023-07-06 15:50:52 -04:00
16 changed files with 648 additions and 220 deletions
+2 -1
View File
@@ -71,10 +71,11 @@ height="80">](https://github.com/vitorpamplona/amethyst/releases)
- [x] Recommended Application Handlers (NIP-89)
- [x] Events with a Subject (NIP-14)
- [x] Generic Reposts (kind:16)
- [x] Live Activities & Live Chats (NIP-102)
- [x] Live Activities & Live Chats (NIP-53)
- [x] Relay Pages (NIP-11)
- [x] HTTP Auth (NIP-98)
- [x] Zapraiser (NIP-TBD)
- [x] Moderated Communities (NIP-172)
- [ ] Marketplace (NIP-15)
- [ ] Image/Video Capture in the app
- [ ] Local Database
+2 -2
View File
@@ -13,8 +13,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 238
versionName "0.66.0"
versionCode 240
versionName "0.66.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -13,9 +13,13 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
import com.vitorpamplona.amethyst.service.NostrCommunityDataSource
import com.vitorpamplona.amethyst.service.NostrDiscoveryDataSource
import com.vitorpamplona.amethyst.service.NostrHashtagDataSource
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
@@ -89,9 +93,13 @@ object ServiceManager {
NostrAccountDataSource.stop()
NostrHomeDataSource.stop()
NostrChannelDataSource.stop()
NostrChatroomDataSource.stop()
NostrChatroomListDataSource.stop()
NostrDiscoveryDataSource.stop()
NostrCommunityDataSource.stop()
NostrHashtagDataSource.stop()
NostrSearchEventOrUserDataSource.stop()
NostrSingleChannelDataSource.stop()
NostrSingleEventDataSource.stop()
NostrSingleUserDataSource.stop()
@@ -47,13 +47,15 @@ object CachedRichTextParser {
}
}
val longDatePattern: Pattern = Pattern.compile("^\\d{4}-\\d{2}-\\d{2}$")
val shortDatePattern: Pattern = Pattern.compile("^\\d{2}-\\d{2}-\\d{2}$")
val numberPattern: Pattern = Pattern.compile("^(-?[\\d.]+)([a-zA-Z%]*)$")
// Group 1 = url, group 4 additional chars
// val noProtocolUrlValidator = Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+)*\\/?)(.*)")
val noProtocolUrlValidator = Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]*[^\\p{IsHan}\\p{IsHiragana}\\p{IsKatakana}])*\\/?)(.*)")
// Android9 seems to have an issue starting this regex.
val noProtocolUrlValidator = try {
Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]*[^\\p{IsHan}\\p{IsHiragana}\\p{IsKatakana}])*\\/?)(.*)")
} catch (e: Exception) {
Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+)*\\/?)(.*)")
}
class RichTextParser() {
fun parseText(
@@ -232,6 +234,12 @@ class RichTextParser() {
return RegularTextSegment(word)
}
companion object {
val longDatePattern: Pattern = Pattern.compile("^\\d{4}-\\d{2}-\\d{2}$")
val shortDatePattern: Pattern = Pattern.compile("^\\d{2}-\\d{2}-\\d{2}$")
val numberPattern: Pattern = Pattern.compile("^(-?[\\d.]+)([a-zA-Z%]*)$")
}
}
@Immutable
@@ -74,7 +74,7 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
kinds = listOf(ChannelMessageEvent.kind),
tags = mapOf("e" to listOf(it)),
since = latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList,
limit = 25 // Remember to consider spam that is being removed from the UI
limit = 50 // Remember to consider spam that is being removed from the UI
)
)
}
@@ -8,6 +8,7 @@ import com.vitorpamplona.amethyst.service.model.CommunityDefinitionEvent
import com.vitorpamplona.amethyst.service.model.CommunityPostApprovalEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesChatMessageEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
@@ -15,6 +16,9 @@ import com.vitorpamplona.amethyst.service.relays.TypedFilter
object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
lateinit var account: Account
val latestEOSEs = EOSEAccount()
val dataSource = "Discovery"
fun createLiveStreamFilter(): TypedFilter {
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)
@@ -27,7 +31,8 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
filter = JsonFilter(
authors = followKeys,
kinds = listOf(LiveActivitiesChatMessageEvent.kind, LiveActivitiesEvent.kind),
limit = 500
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(dataSource)?.relayList
)
)
}
@@ -40,11 +45,12 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
}
return TypedFilter(
types = setOf(FeedType.GLOBAL),
types = setOf(FeedType.PUBLIC_CHATS),
filter = JsonFilter(
authors = followKeys,
kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind, ChannelMessageEvent.kind),
limit = 500
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(dataSource)?.relayList
)
)
}
@@ -61,7 +67,8 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
filter = JsonFilter(
authors = followKeys,
kinds = listOf(CommunityDefinitionEvent.kind, CommunityPostApprovalEvent.kind),
limit = 500
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(dataSource)?.relayList
)
)
}
@@ -80,7 +87,8 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
}.flatten()
),
limit = 500
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(dataSource)?.relayList
)
)
}
@@ -91,7 +99,7 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
if (hashToLoad.isNullOrEmpty()) return null
return TypedFilter(
types = setOf(FeedType.GLOBAL),
types = setOf(FeedType.PUBLIC_CHATS),
filter = JsonFilter(
kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind, ChannelMessageEvent.kind),
tags = mapOf(
@@ -99,7 +107,8 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
}.flatten()
),
limit = 500
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(dataSource)?.relayList
)
)
}
@@ -118,7 +127,8 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
}.flatten()
),
limit = 500
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(dataSource)?.relayList
)
)
}
@@ -21,13 +21,13 @@ object OnlineChecker {
return checkOnlineCache.get(url).online
}
val request = Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.get()
.build()
return try {
val request = Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.get()
.build()
val result = HttpClient.getHttpClient().newCall(request).execute().code == 200
checkOnlineCache.put(url, OnlineCheckResult(System.currentTimeMillis(), result))
result
@@ -1,10 +1,11 @@
package com.vitorpamplona.amethyst.service
fun String.isUTF16Char(pos: Int): Boolean {
println("Test $pos ${Character.charCount(this.codePointAt(pos))}")
return Character.charCount(this.codePointAt(pos)) == 2
}
fun String.firstFullChar(): String {
fun String.firstFullCharOld(): String {
return when (this.length) {
0, 1 -> return this
2, 3 -> return if (isUTF16Char(0)) this.take(2) else this.take(1)
@@ -21,3 +22,58 @@ fun String.firstFullChar(): String {
}
}
}
fun String.firstFullChar(): String {
var isInJoin = false
var start = 0
var previousCharLength = 0
var next: Int
var codePoint: Int
var i = 0
while (i < this.length) {
codePoint = codePointAt(i)
// Skips if it starts with the join char 0x200D
if (codePoint == 0x200D && previousCharLength == 0) {
next = offsetByCodePoints(i, 1)
start = next
} else {
// If join, searches for the next char
if (codePoint == 0xFE0F) {
} else if (codePoint == 0x200D) {
isInJoin = true
} else {
// stops when two chars are not joined together
if ((previousCharLength > 0) && (!isInJoin) && Character.charCount(codePoint) == 1) {
break
}
isInJoin = false
}
// next char to evaluate
next = offsetByCodePoints(i, 1)
previousCharLength += (next - i)
}
i = next
}
// if ends in join, then seachers backwards until a char is found.
if (isInJoin) {
i = previousCharLength - 1
while (i > 0) {
if (this[i].code == 0x200D) {
previousCharLength -= 1
} else {
break
}
i -= 1
}
}
return substring(start, start + previousCharLength)
}
@@ -22,7 +22,7 @@ class CommunityPostApprovalEvent(
fromJson(it, Client.lenient)
}
} catch (e: Exception) {
Log.e("CommunityPostEvent", "Failed to Parse Contained Post $content", e)
Log.w("CommunityPostEvent", "Failed to Parse Contained Post $content", e)
null
}
@@ -116,6 +116,8 @@ private fun RenderTopRouteBar(
when (currentRoute) {
Route.Channel.base -> NoTopBar()
Route.Room.base -> NoTopBar()
Route.Community.base -> NoTopBar()
Route.Hashtag.base -> NoTopBar()
// Route.Profile.route -> TopBarWithBackButton(nav)
Route.Home.base -> HomeTopBar(followLists, scaffoldState, accountViewModel, nav)
Route.Video.base -> StoriesTopBar(followLists, scaffoldState, accountViewModel, nav)
@@ -27,6 +27,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Alignment.Companion.BottomStart
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Alignment.Companion.TopEnd
@@ -65,6 +66,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.ScheduledFlag
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.StdPadding
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
@@ -346,7 +348,6 @@ private fun RenderNoteRow(
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun RenderLiveActivityThumb(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val noteEvent = baseNote.event as? LiveActivitiesEvent ?: return
@@ -569,13 +570,19 @@ fun RenderCommunitiesThumb(baseNote: Note, accountViewModel: AccountViewModel, n
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.SpaceBetween
) {
Row() {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = name,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
Spacer(modifier = StdHorzSpacer)
LikeReaction(baseNote = baseNote, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel)
Spacer(modifier = StdHorzSpacer)
ZapReaction(baseNote = baseNote, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel)
}
description?.let {
@@ -677,13 +684,19 @@ fun RenderChannelThumb(baseNote: Note, channel: Channel, accountViewModel: Accou
Column(
modifier = Modifier.fillMaxWidth().fillMaxHeight()
) {
Row() {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = name,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
Spacer(modifier = StdHorzSpacer)
LikeReaction(baseNote = baseNote, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel)
Spacer(modifier = StdHorzSpacer)
ZapReaction(baseNote = baseNote, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel)
}
description?.let {
@@ -478,88 +478,229 @@ fun CommunityHeader(
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val scope = rememberCoroutineScope()
var expanded = remember { mutableStateOf(false) }
Column(
modifier = modifier.clickable {
expanded.value = !expanded.value
}
) {
ShortCommunityHeader(baseNote, expanded, accountViewModel, nav)
if (expanded.value) {
LongCommunityHeader(baseNote, accountViewModel, nav)
}
}
if (showBottomDiviser) {
Divider(
thickness = 0.25.dp
)
}
}
@Composable
fun LongCommunityHeader(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val noteState by baseNote.live().metadata.observeAsState()
val noteEvent = remember(noteState) { noteState?.note?.event as? CommunityDefinitionEvent } ?: return
Row(
Modifier
.fillMaxWidth()
.clickable {
scope.launch {
nav("Community/${baseNote.idHex}")
}
}
.padding(top = 10.dp)
) {
val channelState by baseNote.live().metadata.observeAsState()
val noteEvent = remember(channelState) { channelState?.note?.event as? CommunityDefinitionEvent } ?: return
val summary = remember(noteState) {
noteEvent.description()?.ifBlank { null }
}
Column(modifier = modifier) {
Column(
Modifier
.weight(1f)
.padding(start = 10.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
noteEvent.image()?.let {
RobohashAsyncImageProxy(
robot = baseNote.idHex,
model = it,
contentDescription = stringResource(R.string.profile_image),
contentScale = ContentScale.Crop,
modifier = Modifier
.padding(start = 10.dp)
.width(Size35dp)
.height(Size35dp)
.clip(shape = CircleShape)
)
}
Column(
modifier = Modifier
.padding(start = 10.dp)
.weight(1f),
verticalArrangement = Arrangement.Center
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = remember(channelState) { noteEvent.dTag() },
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
val summary = remember(channelState) {
noteEvent.description()?.ifBlank { null }
}
if (summary != null) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = summary,
color = MaterialTheme.colors.placeholderText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontSize = 12.sp
)
}
}
}
Row(
modifier = Modifier
.height(Size35dp)
.padding(start = 5.dp),
verticalAlignment = Alignment.CenterVertically
) {
CommunityActionOptions(baseNote, accountViewModel, nav)
}
Text(
text = summary ?: "This group does not have a description or rules. Talk to the owner to add one"
)
}
}
if (showBottomDiviser) {
Divider(
thickness = 0.25.dp
Column() {
Row() {
Spacer(DoubleHorzSpacer)
LongCommunityActionOptions(baseNote, accountViewModel, nav)
}
}
}
val rules = remember(noteState) {
noteEvent.rules()?.ifBlank { null }
}
rules?.let {
Spacer(DoubleVertSpacer)
Row(
Modifier
.fillMaxWidth()
.padding(start = 10.dp)
) {
Text(
text = it
)
}
}
Spacer(DoubleVertSpacer)
Row(Modifier.fillMaxWidth().padding(start = 10.dp), verticalAlignment = Alignment.CenterVertically) {
Text(
text = stringResource(id = R.string.owner),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.width(75.dp)
)
Spacer(DoubleHorzSpacer)
NoteAuthorPicture(baseNote, nav, accountViewModel, Size25dp)
Spacer(DoubleHorzSpacer)
NoteUsernameDisplay(baseNote, remember { Modifier.weight(1f) })
TimeAgo(baseNote)
MoreOptionsButton(baseNote, accountViewModel)
}
var participantUsers by remember(baseNote) {
mutableStateOf<ImmutableList<Pair<Participant, User>>>(
persistentListOf()
)
}
LaunchedEffect(key1 = noteState) {
launch(Dispatchers.IO) {
val noteEvent = (noteState?.note?.event as? CommunityDefinitionEvent)
val newParticipantUsers = noteEvent?.moderators()?.mapNotNull { part ->
LocalCache.checkGetOrCreateUser(part.key)?.let { Pair(part, it) }
}?.toImmutableList()
if (newParticipantUsers != null && !equalImmutableLists(newParticipantUsers, participantUsers)) {
participantUsers = newParticipantUsers
}
}
}
participantUsers.forEach {
Row(
Modifier.fillMaxWidth()
.padding(start = 10.dp, top = 10.dp)
.clickable {
nav("User/${it.second.pubkeyHex}")
},
verticalAlignment = Alignment.CenterVertically
) {
it.first.role?.let { it1 ->
Text(
text = it1.capitalize(Locale.ROOT),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.width(75.dp)
)
}
Spacer(DoubleHorzSpacer)
ClickableUserPicture(it.second, Size25dp, accountViewModel)
Spacer(DoubleHorzSpacer)
UsernameDisplay(it.second, remember { Modifier.weight(1f) })
}
}
}
@Composable
private fun CommunityActionOptions(
fun ShortCommunityHeader(baseNote: Note, expanded: MutableState<Boolean>, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val noteState by baseNote.live().metadata.observeAsState()
val noteEvent = remember(noteState) { noteState?.note?.event as? CommunityDefinitionEvent } ?: return
Row(verticalAlignment = Alignment.CenterVertically) {
noteEvent.image()?.let {
RobohashAsyncImageProxy(
robot = baseNote.idHex,
model = it,
contentDescription = stringResource(R.string.profile_image),
contentScale = ContentScale.Crop,
modifier = Modifier
.padding(start = 10.dp)
.width(Size35dp)
.height(Size35dp)
.clip(shape = CircleShape)
)
}
Column(
modifier = Modifier
.padding(start = 10.dp)
.height(Size35dp)
.weight(1f),
verticalArrangement = Arrangement.Center
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = remember(noteState) { noteEvent.dTag() },
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
val summary = remember(noteState) {
noteEvent.description()?.ifBlank { null }
}
if (summary != null && !expanded.value) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = summary,
color = MaterialTheme.colors.placeholderText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontSize = 12.sp
)
}
}
}
Row(
modifier = Modifier
.height(Size35dp)
.padding(start = 5.dp),
verticalAlignment = Alignment.CenterVertically
) {
ShortCommunityActionOptions(baseNote, accountViewModel, nav)
}
}
}
@Composable
private fun ShortCommunityActionOptions(
note: Note,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val isFollowing by remember(accountState) {
derivedStateOf {
accountState?.account?.followingCommunities?.contains(note.idHex) ?: false
}
}
Spacer(modifier = StdHorzSpacer)
LikeReaction(baseNote = note, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel)
Spacer(modifier = StdHorzSpacer)
ZapReaction(baseNote = note, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel)
if (!isFollowing) {
Spacer(modifier = StdHorzSpacer)
JoinCommunityButton(accountViewModel, note, nav)
}
}
@Composable
private fun LongCommunityActionOptions(
note: Note,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
@@ -573,8 +714,6 @@ private fun CommunityActionOptions(
if (isFollowing) {
LeaveCommunityButton(accountViewModel, note, nav)
} else {
JoinCommunityButton(accountViewModel, note, nav)
}
}
@@ -2098,14 +2237,14 @@ fun MoreOptionsButton(
@Composable
fun TimeAgo(note: Note) {
val time = remember { note.createdAt() } ?: return
val time = remember(note) { note.createdAt() } ?: return
TimeAgo(time)
}
@Composable
fun TimeAgo(time: Long) {
val context = LocalContext.current
val timeStr by remember { mutableStateOf(timeAgo(time, context = context)) }
val timeStr by remember(time) { mutableStateOf(timeAgo(time, context = context)) }
Text(
text = timeStr,
@@ -2447,7 +2586,7 @@ private fun getCommunityShortName(communityTag: ATag): String {
communityTag.dTag.take(10)
}
return "/c/$name"
return "/n/$name"
}
@OptIn(ExperimentalLayoutApi::class)
@@ -141,7 +141,7 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
notes
.filter { it.event is LnZapEvent }
.forEach { zapEvent ->
val zappedPost = zapEvent.replyTo?.lastOrNull() { it.event !is ChannelMetadataEvent && it.event !is ChannelCreateEvent }
val zappedPost = zapEvent.replyTo?.lastOrNull()
if (zappedPost != null) {
val zapRequest = zappedPost.zaps.filter { it.value == zapEvent }.keys.firstOrNull()
if (zapRequest != null) {
@@ -44,6 +44,7 @@ import androidx.compose.material.icons.filled.Share
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
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
@@ -75,6 +76,8 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Channel
@@ -82,35 +85,50 @@ import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent.Companion.STATUS_LIVE
import com.vitorpamplona.amethyst.service.model.Participant
import com.vitorpamplona.amethyst.ui.actions.NewChannelView
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
import com.vitorpamplona.amethyst.ui.actions.PostButton
import com.vitorpamplona.amethyst.ui.actions.ServersAvailable
import com.vitorpamplona.amethyst.ui.actions.UploadFromGallery
import com.vitorpamplona.amethyst.ui.components.LoadNote
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
import com.vitorpamplona.amethyst.ui.components.ZoomableUrlVideo
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.note.ChatroomMessageCompose
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.LikeReaction
import com.vitorpamplona.amethyst.ui.note.MoreOptionsButton
import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture
import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay
import com.vitorpamplona.amethyst.ui.note.TimeAgo
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.note.ZapReaction
import com.vitorpamplona.amethyst.ui.note.timeAgo
import com.vitorpamplona.amethyst.ui.screen.NostrChannelFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.RefreshingChatroomFeedView
import com.vitorpamplona.amethyst.ui.screen.equalImmutableLists
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.Size25dp
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.SmallBorder
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.StdPadding
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.Locale
@Composable
fun ChannelScreen(
@@ -556,117 +574,22 @@ fun ChannelHeader(
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val scope = rememberCoroutineScope()
Column(
Modifier
.fillMaxWidth()
.clickable {
scope.launch {
nav("Channel/${baseChannel.idHex}")
}
}
) {
val channelState by baseChannel.live.observeAsState()
val channel = remember(channelState) { channelState?.channel } ?: return
if (showVideo) {
val streamingUrl by remember(channelState) {
derivedStateOf {
val activity = channel as? LiveActivitiesChannel
val description = activity?.info?.title()
val url = activity?.info?.streaming()
if (url != null) {
ZoomableUrlVideo(url, description = description)
} else {
null
}
}
}
streamingUrl?.let {
CheckIfUrlIsOnline(it.url) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = remember { Modifier.heightIn(max = 300.dp) }
) {
ZoomableContentView(
content = it
)
}
}
}
Column(Modifier.fillMaxWidth()) {
if (showVideo && baseChannel is LiveActivitiesChannel) {
ShowVideoStreaming(baseChannel)
}
Column(modifier = modifier) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (channel is LiveActivitiesChannel) {
channel.creator?.let {
UserPicture(
user = it,
size = Size35dp,
accountViewModel = accountViewModel,
nav = nav
)
}
} else {
channel.profilePicture()?.let {
RobohashAsyncImageProxy(
robot = channel.idHex,
model = it,
contentDescription = stringResource(R.string.profile_image),
contentScale = ContentScale.Crop,
modifier = Modifier.padding(start = 10.dp)
.width(Size35dp)
.height(Size35dp)
.clip(shape = CircleShape)
)
}
}
Column(
modifier = Modifier
.padding(start = 10.dp)
.weight(1f),
verticalArrangement = Arrangement.Center
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = remember(channelState) { channel.toBestDisplayName() },
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
var expanded = remember { mutableStateOf(false) }
val summary = remember(channelState) {
channel.summary()?.ifBlank { null }
}
Column(
modifier = modifier.clickable {
expanded.value = !expanded.value
}
) {
ShortChannelHeader(baseChannel, expanded, accountViewModel, nav, showFlag)
if (summary != null) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = summary,
color = MaterialTheme.colors.placeholderText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontSize = 12.sp
)
}
}
}
Row(
modifier = Modifier
.height(Size35dp)
.padding(start = 5.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (channel is PublicChatChannel) {
ChannelActionOptions(channel, accountViewModel, nav)
}
if (channel is LiveActivitiesChannel) {
LiveChannelActionOptions(channel, showFlag, accountViewModel, nav)
}
}
if (expanded.value) {
LongChannelHeader(baseChannel, accountViewModel, nav)
}
}
@@ -679,13 +602,264 @@ fun ChannelHeader(
}
@Composable
private fun ChannelActionOptions(
private fun ShowVideoStreaming(
baseChannel: LiveActivitiesChannel
) {
val streamingUrl by baseChannel.live.map {
val activity = it.channel as? LiveActivitiesChannel
activity?.info?.streaming()
}.distinctUntilChanged().observeAsState(baseChannel.info?.streaming())
streamingUrl?.let {
CheckIfUrlIsOnline(it) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = remember { Modifier.heightIn(max = 300.dp) }
) {
val zoomableUrlVideo = remember(it) {
ZoomableUrlVideo(url = it)
}
ZoomableContentView(
content = zoomableUrlVideo
)
}
}
}
}
@Composable
private fun ShortChannelHeader(
baseChannel: Channel,
expanded: MutableState<Boolean>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
showFlag: Boolean
) {
val channelState = baseChannel.live.observeAsState()
val channel = remember(channelState) {
channelState.value?.channel
} ?: return
Row(verticalAlignment = Alignment.CenterVertically) {
if (channel is LiveActivitiesChannel) {
channel.creator?.let {
UserPicture(
user = it,
size = Size35dp,
accountViewModel = accountViewModel,
nav = nav
)
}
} else {
channel.profilePicture()?.let {
RobohashAsyncImageProxy(
robot = channel.idHex,
model = it,
contentDescription = stringResource(R.string.profile_image),
contentScale = ContentScale.Crop,
modifier = Modifier
.padding(start = 10.dp)
.width(Size35dp)
.height(Size35dp)
.clip(shape = CircleShape)
)
}
}
Column(
modifier = Modifier
.padding(start = 10.dp)
.height(35.dp)
.weight(1f),
verticalArrangement = Arrangement.Center
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = remember(channelState) { channel.toBestDisplayName() },
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
val summary = remember(channelState) {
channel.summary()?.ifBlank { null }
}
if (summary != null && !expanded.value) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = summary,
color = MaterialTheme.colors.placeholderText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontSize = 12.sp
)
}
}
}
Row(
modifier = Modifier
.height(Size35dp)
.padding(start = 5.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (channel is PublicChatChannel) {
ShortChannelActionOptions(channel, accountViewModel, nav)
}
if (channel is LiveActivitiesChannel) {
LiveChannelActionOptions(channel, showFlag, accountViewModel, nav)
}
}
}
}
@Composable
private fun LongChannelHeader(
baseChannel: Channel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val channelState = baseChannel.live.observeAsState()
val channel = remember(channelState) {
channelState.value?.channel
} ?: return
Row(
Modifier
.fillMaxWidth()
.padding(top = 10.dp)
) {
val summary = remember(channelState) {
channel.summary()?.ifBlank { null }
}
Column(
Modifier
.weight(1f)
.padding(start = 10.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = summary ?: "This group does not have a description or rules. Talk to the owner to add one"
)
}
}
Column() {
if (channel is PublicChatChannel) {
Row() {
Spacer(DoubleHorzSpacer)
LongChannelActionOptions(channel, accountViewModel, nav)
}
}
}
}
Spacer(DoubleVertSpacer)
Row(Modifier.fillMaxWidth().padding(start = 10.dp), verticalAlignment = Alignment.CenterVertically) {
LoadNote(baseNoteHex = channel.idHex) {
it?.let {
Text(
text = stringResource(id = R.string.owner),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.width(55.dp)
)
Spacer(DoubleHorzSpacer)
NoteAuthorPicture(it, nav, accountViewModel, Size25dp)
Spacer(DoubleHorzSpacer)
NoteUsernameDisplay(it, remember { Modifier.weight(1f) })
TimeAgo(it)
MoreOptionsButton(it, accountViewModel)
}
}
}
var participantUsers by remember(baseChannel) {
mutableStateOf<ImmutableList<Pair<Participant, User>>>(
persistentListOf()
)
}
if (channel is LiveActivitiesChannel) {
LaunchedEffect(key1 = channelState) {
launch(Dispatchers.IO) {
val newParticipantUsers = channel.info?.participants()?.mapNotNull { part ->
LocalCache.checkGetOrCreateUser(part.key)?.let { Pair(part, it) }
}?.toImmutableList()
if (newParticipantUsers != null && !equalImmutableLists(newParticipantUsers, participantUsers)) {
participantUsers = newParticipantUsers
}
}
}
participantUsers.forEach {
Row(
Modifier.fillMaxWidth()
.padding(start = 10.dp, top = 10.dp)
.clickable {
nav("User/${it.second.pubkeyHex}")
},
verticalAlignment = Alignment.CenterVertically
) {
it.first.role?.let { it1 ->
Text(
text = it1.capitalize(Locale.ROOT),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.width(55.dp)
)
}
Spacer(DoubleHorzSpacer)
ClickableUserPicture(it.second, Size25dp, accountViewModel)
Spacer(DoubleHorzSpacer)
UsernameDisplay(it.second, remember { Modifier.weight(1f) })
}
}
}
}
@Composable
private fun ShortChannelActionOptions(
channel: PublicChatChannel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
NoteCopyButton(channel)
val accountState by accountViewModel.accountLiveData.observeAsState()
val isFollowing by remember(accountState) {
derivedStateOf {
accountState?.account?.followingChannels?.contains(channel.idHex) ?: false
}
}
LoadNote(baseNoteHex = channel.idHex) {
it?.let {
var popupExpanded by remember { mutableStateOf(false) }
Spacer(modifier = StdHorzSpacer)
LikeReaction(baseNote = it, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel)
Spacer(modifier = StdHorzSpacer)
ZapReaction(baseNote = it, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel)
Spacer(modifier = StdHorzSpacer)
}
}
if (!isFollowing) {
JoinChatButton(accountViewModel, channel, nav)
}
}
@Composable
private fun LongChannelActionOptions(
channel: PublicChatChannel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val isMe by remember(accountViewModel) {
derivedStateOf {
channel.creator == accountViewModel.account.userProfile()
@@ -705,8 +879,6 @@ private fun ChannelActionOptions(
if (isFollowing) {
LeaveChatButton(accountViewModel, channel, nav)
} else {
JoinChatButton(accountViewModel, channel, nav)
}
}
@@ -891,7 +1063,6 @@ fun LeaveChatButton(accountViewModel: AccountViewModel, channel: Channel, nav: (
modifier = Modifier.padding(horizontal = 3.dp),
onClick = {
accountViewModel.account.leaveChannel(channel.idHex)
nav(Route.Message.route)
},
shape = ButtonBorder,
colors = ButtonDefaults
+2 -2
View File
@@ -474,7 +474,7 @@
<string name="relay_setup">Relays</string>
<string name="discover_live">Live</string>
<string name="discover_community">Communities</string>
<string name="discover_chat">Chat Groups</string>
<string name="discover_community">Community</string>
<string name="discover_chat">Chats</string>
<string name="community_approved_posts">Approved Posts</string>
</resources>
@@ -11,12 +11,12 @@ class CharsetTest {
}
@Test
fun testUTF16Char() {
fun testUTF16JoinChar() {
Assert.assertEquals("\uD83C\uDF48", "\uD83C\uDF48Hi".firstFullChar())
}
@Test
fun testUTF32Char() {
fun testUTF32JoinChar() {
Assert.assertEquals("\uD83E\uDDD1\uD83C\uDFFE", "\uD83E\uDDD1\uD83C\uDFFEHi".firstFullChar())
}
@@ -34,4 +34,24 @@ class CharsetTest {
fun testSpecialChars() {
Assert.assertEquals("=", "=x".firstFullChar())
}
@Test
fun test5CharEmoji() {
Assert.assertEquals("\uD83D\uDC68\u200D\uD83D\uDCBB", "\uD83D\uDC68\u200D\uD83D\uDCBBadsfasdf".firstFullChar())
}
@Test
fun testFamily() {
Assert.assertEquals("\uD83D\uDC68\u200d\uD83D\uDC69\u200d\uD83D\uDC67\u200d\uD83D\uDC67", "\uD83D\uDC68\u200D\uD83D\uDC69\u200D\uD83D\uDC67\u200D\uD83D\uDC67adsfasdf".firstFullChar())
}
@Test
fun testTeacher() {
Assert.assertEquals("\uD83E\uDDD1\uD83C\uDFFF\u200D\uD83C\uDFEB", "\uD83E\uDDD1\uD83C\uDFFF\u200D\uD83C\uDFEBasdf".firstFullChar())
}
@Test
fun testVariation() {
Assert.assertEquals("\uD83D\uDC68\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68", "\uD83D\uDC68\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68ddd".firstFullChar())
}
}