Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 36 additions & 16 deletions Flipcash/Core/Screens/Main/Currency Info/CommunityChats.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,41 +53,61 @@ struct CommunityChatRow: View {

var body: some View {
Button(action: onTap) {
HStack(spacing: 12) {
CommunityRowCard(title: chat.title ?? "", subtitle: subtitle, showsChevron: true) {
ContactAvatarView(
id: chat.id.description,
displayName: chat.title ?? "",
imageData: sessionContainer.profileAvatars.data(for: .chat(chat.id)),
blurhash: chat.picture?.thumbnailBlurhash,
size: 42
)
VStack(alignment: .leading, spacing: 3) {
Text(chat.title ?? "")
.font(.appTextMedium)
.foregroundStyle(Color.textMain)
.lineLimit(1)
}
}
.buttonStyle(.plain)
.task(id: chat.id) {
await sessionContainer.profileAvatars.load(.chat(chat.id), picture: chat.picture)
}
}
}

/// The card both community rows draw: a 42pt avatar, a title over an optional subtitle, and an
/// optional chevron. Without a subtitle the title sits centred.
struct CommunityRowCard<Avatar: View>: View {

let title: String
let subtitle: String?
let showsChevron: Bool
@ViewBuilder let avatar: () -> Avatar

var body: some View {
HStack(spacing: 12) {
avatar()
VStack(alignment: .leading, spacing: 3) {
Text(title)
.font(.appTextMedium)
.foregroundStyle(Color.textMain)
.lineLimit(1)
if let subtitle {
Text(subtitle)
.font(.appTextSmall)
.foregroundStyle(Color.white.opacity(0.55))
.lineLimit(1)
.minimumScaleFactor(0.8)
}
Spacer(minLength: 0)
}
Spacer(minLength: 0)
if showsChevron {
Image(systemName: "chevron.right")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Color.white.opacity(0.55))
.frame(width: 16, height: 16)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.frame(height: 67)
.background(RoundedRectangle(cornerRadius: 12).fill(Color.backgroundRow))
.contentShape(RoundedRectangle(cornerRadius: 12))
}
.buttonStyle(.plain)
.task(id: chat.id) {
await sessionContainer.profileAvatars.load(.chat(chat.id), picture: chat.picture)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.frame(height: 67)
.background(RoundedRectangle(cornerRadius: 12).fill(Color.backgroundRow))
.contentShape(RoundedRectangle(cornerRadius: 12))
}
}

Expand Down
96 changes: 96 additions & 0 deletions Flipcash/Core/Screens/Main/Currency Info/CommunityLeader.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
//
// CommunityLeader.swift
// Flipcash
//
// Community Leader on Currency Info: the Flipcash user who launched the currency, one row that
// opens their profile.
//

import SwiftUI
import FlipcashUI
import FlipcashCore

/// The text and visibility rules for the community leader, apart from the views so Android's
/// matching ones can be checked against them.
nonisolated enum CommunityLeader {

static let title = "Community Leader"

/// The leader the section shows: none when the server named nobody or the profile has no
/// display name.
static func shown(_ leader: Profile?) -> Profile? {
guard let leader, let name = leader.displayName, !name.isEmpty else { return nil }
return leader
}

/// "@jeff", or nil when the leader has no username, which leaves the name on its own.
static func subtitle(for leader: Profile) -> String? {
leader.username?.handle
}

/// "Community Leader, Jeff Yanta, @jeff. Opens profile.", the last sentence only when the row
/// opens the profile.
static func accessibilityLabel(for leader: Profile, isTappable: Bool) -> String {
let parts = [title, leader.displayName ?? "", subtitle(for: leader)].compactMap { $0 }
let label = parts.joined(separator: ", ")
return isTappable ? "\(label). Opens profile." : label
}
}

/// The leader's card. Tappable, with a chevron, only when the profile carries a user id.
struct CommunityLeaderRow: View {

@Environment(SessionContainer.self) private var sessionContainer

let leader: Profile
let onTap: (UserID) -> Void

var body: some View {
Group {
if let userID = leader.userID {
Button { onTap(userID) } label: { card(showsChevron: true) }
.buttonStyle(.plain)
.accessibilityLabel(CommunityLeader.accessibilityLabel(for: leader, isTappable: true))
} else {
card(showsChevron: false)
.accessibilityElement(children: .ignore)
.accessibilityLabel(CommunityLeader.accessibilityLabel(for: leader, isTappable: false))
}
}
.task(id: leader.userID) {
await sessionContainer.profileAvatars.load(userID: leader.userID, picture: leader.profilePicture)
}
}

private func card(showsChevron: Bool) -> some View {
let name = leader.displayName ?? ""
return CommunityRowCard(title: name, subtitle: CommunityLeader.subtitle(for: leader), showsChevron: showsChevron) {
ContactAvatarView(
id: leader.userID?.uuidString ?? name,
displayName: name,
imageData: leader.userID.flatMap { sessionContainer.profileAvatars.data(for: .user($0)) },
blurhash: leader.profilePicture?.thumbnailBlurhash,
size: 42
)
}
}
}

/// "Community Leader" over the leader's row.
struct CommunityLeaderSection: View {

let leader: Profile
let onSelect: (UserID) -> Void

var body: some View {
VStack(alignment: .leading, spacing: 14) {
Text("Community Leader")
.font(.default(size: 19, weight: .bold))
.foregroundStyle(Color.textMain)
.frame(minHeight: 28)
.accessibilityAddTraits(.isHeader)
CommunityLeaderRow(leader: leader, onTap: onSelect)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ struct CurrencyInfoContentV2: View {
let onSelectCommunityChat: (Conversation) -> Void
let onShowCommunityRankingInfo: () -> Void
let onShowAllCommunityChats: () -> Void
/// Opens the community leader's profile.
let onSelectCommunityLeader: (UserID) -> Void
/// Opens "How Market Cap Works".
let onShowMarketCapExplainer: () -> Void
/// Fires when the hero card's title scrolls out from under the toolbar, so
Expand Down Expand Up @@ -137,16 +139,26 @@ struct CurrencyInfoContentV2: View {
actionTiles
.padding(.horizontal, 20)

CommunityChatsSection(
chats: viewModel.communityChats,
currencyName: metadata.name,
subtitle: viewModel.communitySubtitle(for:),
onSelect: onSelectCommunityChat,
onInfo: onShowCommunityRankingInfo,
onViewAll: onShowAllCommunityChats
)
.padding(.horizontal, 20)
.padding(.top, 24)
// Gated as a whole so a currency with neither section (USDF among them)
// keeps About where it was, rather than under an empty, padded stack.
if !viewModel.communityChats.isEmpty || viewModel.communityLeader != nil {
VStack(spacing: 24) {
CommunityChatsSection(
chats: viewModel.communityChats,
currencyName: metadata.name,
subtitle: viewModel.communitySubtitle(for:),
onSelect: onSelectCommunityChat,
onInfo: onShowCommunityRankingInfo,
onViewAll: onShowAllCommunityChats
)

if let leader = viewModel.communityLeader {
CommunityLeaderSection(leader: leader, onSelect: onSelectCommunityLeader)
}
}
.padding(.horizontal, 20)
.padding(.top, 24)
}

CurrencyInfoAboutSection(
description: currencyDescription,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,7 @@ private struct CurrencyInfoScreenContent: View {
},
onShowCommunityRankingInfo: { session.dialogItem = .info(title: CommunityChats.rankingTitle, subtitle: CommunityChats.rankingMessage(currencyName: metadata.name)) },
onShowAllCommunityChats: { router.push(.topCommunityChats(metadata.mint)) },
onSelectCommunityLeader: { userID in router.push(.userProfile(userID, origin: .communityLeader)) },
onShowMarketCapExplainer: { router.push(.marketCapExplainer(metadata.mint)) },
onScrolledPastTitle: { scrolledPast in
guard showsToolbarTitle != scrolledPast else { return }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ class CurrencyInfoViewModel {

@ObservationIgnored private let mint: PublicKey
@ObservationIgnored private let session: Session
@ObservationIgnored private let communityFetcher: any CommunityFetching
@ObservationIgnored private let database: Database
@ObservationIgnored private let ratesController: RatesController

Expand Down Expand Up @@ -139,9 +140,11 @@ class CurrencyInfoViewModel {

/// Initializes with a mint address. Attempts a fast database lookup;
/// falls back to loading state until ``loadMintMetadata()`` completes.
init(mint: PublicKey, session: Session, database: Database, ratesController: RatesController) {
/// The community loads through `communityFetcher`, or `session` when nil.
init(mint: PublicKey, session: Session, database: Database, ratesController: RatesController, communityFetcher: (any CommunityFetching)? = nil) {
self.mint = mint
self.session = session
self.communityFetcher = communityFetcher ?? session
self.database = database
self.ratesController = ratesController

Expand All @@ -156,6 +159,10 @@ class CurrencyInfoViewModel {
/// fails, which is how the section hides itself.
private(set) var communityChats: [Conversation] = []

/// The Flipcash user who launched the currency, from the same call as the chats. Nil until
/// loaded, when the call fails, and when the server names no leader with a display name.
private(set) var communityLeader: Profile?

/// "$10,840 of Jeffy held by 44 members", the holdings valued at the curve's current supply and
/// shown in the user's display currency.
func communitySubtitle(for chat: Conversation) -> String {
Expand All @@ -175,11 +182,16 @@ class CurrencyInfoViewModel {
)
}

/// Loads the community's chats and leader. USDF has no community, so it makes no call.
func loadCommunityChats() async {
guard mint != .usdf else { return }
do {
communityChats = try await session.fetchCommunity(mint: mint).topHolderChats
let community = try await communityFetcher.fetchCommunity(mint: mint)
communityChats = community.topHolderChats
communityLeader = CommunityLeader.shown(community.leader)
} catch {
communityChats = []
communityLeader = nil
}
}

Expand Down
14 changes: 8 additions & 6 deletions Flipcash/Core/Screens/Profile/UserProfileScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -418,14 +418,16 @@ nonisolated enum UserProfileOrigin: Hashable {
case transaction
/// A request to join a private group the user hosts.
case joinRequest
/// The leader row on a currency's info screen.
case communityLeader

/// Whether Open Chat returns to the DM the profile was opened from rather than pushing a
/// second copy of it.
var returnsToExistingDM: Bool {
switch self {
case .directMessage:
return true
case .groupMember, .mention, .deeplink, .scan, .usernameLookup, .transaction, .joinRequest:
case .groupMember, .mention, .deeplink, .scan, .usernameLookup, .transaction, .joinRequest, .communityLeader:
return false
}
}
Expand All @@ -437,19 +439,19 @@ nonisolated enum UserProfileOrigin: Hashable {
switch self {
case .deeplink, .scan, .usernameLookup:
return true
case .directMessage, .groupMember, .mention, .transaction, .joinRequest:
case .directMessage, .groupMember, .mention, .transaction, .joinRequest, .communityLeader:
return false
}
}

/// Whether blocking closes just the profile rather than resetting its stack. From a chat the
/// stack beneath can hold the blocked person's DM, so it resets; a link opened the profile over
/// whatever the user was on, and that is where blocking returns them, as does a transaction's
/// details. A scan or a username search opens the profile as the Chats tab's only entry, so a
/// whatever the user was on, and that is where blocking returns them, as do a transaction's
/// details and a currency's leader row. A scan or a username search opens the profile as the Chats tab's only entry, so a
/// reset lands on the chat list either way.
var blockReturnsToOpener: Bool {
switch self {
case .deeplink, .transaction, .joinRequest:
case .deeplink, .transaction, .joinRequest, .communityLeader:
return true
case .directMessage, .groupMember, .mention, .scan, .usernameLookup:
return false
Expand All @@ -462,7 +464,7 @@ nonisolated enum UserProfileOrigin: Hashable {
switch self {
case .scan:
return .tipcard
case .directMessage, .groupMember, .mention, .deeplink, .usernameLookup, .transaction, .joinRequest:
case .directMessage, .groupMember, .mention, .deeplink, .usernameLookup, .transaction, .joinRequest, .communityLeader:
return .chat
}
}
Expand Down
8 changes: 8 additions & 0 deletions Flipcash/Core/Session/SessionProtocols.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ protocol MintMetadataFetching: AnyObject {
func fetchMintMetadata(mint: PublicKey) async throws -> StoredMintMetadata
}

// MARK: - Community

/// The community around a launched currency: its leader and ranked top-holder chats.
protocol CommunityFetching: AnyObject {
func fetchCommunity(mint: PublicKey) async throws -> Community
}

// MARK: - Buy paths

/// Reserve-funded buys (existing currency or freshly launched).
Expand Down Expand Up @@ -137,6 +144,7 @@ extension Session: AccountProviding,
ProfileProviding,
ProfileManaging,
MintMetadataFetching,
CommunityFetching,
ReservesBuying,
CurrencyLaunching,
RecipientResolving,
Expand Down
Loading
Loading