Files
Evgeny ad23da63d0 core: supporter badges using anonymous BBS credentials (#7040)
* core: supporter badges using anonymous BBS credentials

* badges in profiles

* badge in profiles

* process badges

* update simplexmq

* update simplexmq

* change types

* fix migration

* migration

* update simplexmq

* fix bot API, schema

* fix postgresql build

* refactor

* postgresql schema

* correctly set badges in all cases

* badges ffi

* plan, bot types

* FFI

* FFI: export badge symbols

* add extra field

* refactor badge types to GADT

* configurable badge key

* add badge to profile, test

* ui: badge images

* generate badge key and sign badge

* badge sign in CLI

* fix commands, ui

* rename badges

* Binary

* image size, migration

* update badge images, add public key

* send badges in more cases

* update UI, tests

* bot types, schema

* postgres schema

* tone down badges

* revert formula

* refactor badges

* smaller badges

* badge position

* better badge position

* simpler

* position

* move position

* update simplexmq

* show badge after name

* badge layout

* fix badge

* debug badge height

* shift badge

* fix badge in member name

* bigger badge

* badge layout

* differentiate badge colors

* more avatars for the user's profiles

* refactor

* remove color filter

* alerts

* multiple keys, old expired

* use new BBS api

* update badge keys, bot api

* presentation header

* simplify

* parser

* update iOS images

* update public keys

* query plans

* update simplexmq

* refactor badge types

* simplexmq

* bot api types

* update simplexmq - commoncrypto flag

* update simplexmq

* pass commoncrypto flag to simplexmq in nix iOS build

* ios ui

* update core library, fixes

* badge layout

* badge size

* badge gap

* remove extensions

* simplify

* share badge in more events, reverify badge if verification failed

* larger files with badges

* allow sending larger files

* simpler

* update simplexmq

* better decoder for badge keys

* update simplexmq

---------

Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
Co-authored-by: shum <github.shum@liber.li>
2026-06-15 22:25:08 +01:00

300 lines
12 KiB
Swift

//
// Created by Avently on 16.01.2023.
// Copyright (c) 2023 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
// Spec: spec/client/chat-list.md#UserPicker
struct UserPicker: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
@Environment(\.scenePhase) private var scenePhase: ScenePhase
@Environment(\.colorScheme) private var colorScheme: ColorScheme
@Binding var userPickerShown: Bool
@Binding var activeSheet: UserPickerSheet?
@State private var currentUser: Int64?
@State private var switchingProfile = false
@State private var frameWidth: CGFloat = 0
@State private var resetScroll = ResetScrollAction()
// Inset grouped list dimensions
private let imageSize: CGFloat = 44
private let rowPadding: CGFloat = 16
private let rowVerticalPadding: CGFloat = 11
private let sectionSpacing: CGFloat = 35
private var sectionHorizontalPadding: CGFloat { frameWidth > 375 ? 20 : 16 }
private let sectionShape = RoundedRectangle(cornerRadius: 10, style: .continuous)
var body: some View {
let otherUsers: [UserInfo] = m.users
.filter { u in !u.user.hidden && u.user.userId != m.currentUser?.userId }
.sorted(using: KeyPathComparator<UserInfo>(\.user.activeOrder, order: .reverse))
let sectionWidth = max(frameWidth - sectionHorizontalPadding * 2, 0)
let currentUserWidth = max(frameWidth - sectionHorizontalPadding - rowPadding * 2 - 14 - imageSize, 0)
let stopped = m.chatRunning != true
VStack(spacing: sectionSpacing) {
if let user = m.currentUser {
StickyScrollView(resetScroll: $resetScroll) {
HStack(spacing: rowPadding) {
HStack {
ProfileImage(imageStr: user.image, size: imageSize, color: Color(uiColor: .tertiarySystemGroupedBackground))
.padding(.trailing, 6)
profileName(user).lineLimit(1)
}
.padding(rowPadding)
.frame(width: otherUsers.isEmpty ? sectionWidth : currentUserWidth, alignment: .leading)
.modifier(ListRow { activeSheet = .currentProfile })
.clipShape(sectionShape)
.disabled(stopped)
.opacity(stopped ? 0.4 : 1)
ForEach(otherUsers) { u in
userView(u, size: imageSize)
.frame(maxWidth: sectionWidth * 0.618)
.fixedSize()
.disabled(stopped)
.opacity(stopped ? 0.4 : 1)
}
}
.padding(.horizontal, sectionHorizontalPadding)
}
.frame(height: 2 * rowPadding + imageSize)
.padding(.top, sectionSpacing)
.overlay(DetermineWidth())
.onPreferenceChange(DetermineWidth.Key.self) { frameWidth = $0 }
}
VStack(spacing: 0) {
openSheetOnTap("qrcode", title: m.userAddress == nil ? "Create SimpleX address" : "Your SimpleX address", sheet: .address, disabled: stopped)
openSheetOnTap("switch.2", title: "Chat preferences", sheet: .chatPreferences, disabled: stopped)
openSheetOnTap("person.crop.rectangle.stack", title: "Your chat profiles", sheet: .chatProfiles, disabled: stopped)
openSheetOnTap("desktopcomputer", title: "Use from desktop", sheet: .useFromDesktop, disabled: stopped)
ZStack(alignment: .trailing) {
openSheetOnTap("gearshape", title: "Settings", sheet: .settings, showDivider: false)
Image(systemName: colorScheme == .light ? "sun.max" : "moon.fill")
.resizable()
.scaledToFit()
.symbolRenderingMode(.monochrome)
.foregroundColor(theme.colors.secondary)
.frame(maxWidth: 20, maxHeight: .infinity)
.padding(.horizontal, rowPadding)
.background(Color(.systemBackground).opacity(0.01))
.onTapGesture {
if (colorScheme == .light) {
ThemeManager.applyTheme(systemDarkThemeDefault.get())
} else {
ThemeManager.applyTheme(DefaultTheme.LIGHT.themeName)
}
}
.onLongPressGesture {
ThemeManager.applyTheme(DefaultTheme.SYSTEM_THEME_NAME)
}
}
}
.clipShape(sectionShape)
.padding(.horizontal, sectionHorizontalPadding)
.padding(.bottom, sectionSpacing)
}
.onAppear {
// This check prevents the call of listUsers after the app is suspended, and the database is closed.
if case .active = scenePhase, hasChatCtrl() {
currentUser = m.currentUser?.userId
Task {
do {
let users = try await listUsersAsync()
await MainActor.run {
m.users = users
currentUser = m.currentUser?.userId
}
} catch {
logger.error("Error loading users \(responseError(error))")
}
}
}
}
.onChange(of: userPickerShown) {
if !$0 { resetScroll() }
}
.modifier(ThemedBackground(grouped: true))
.disabled(switchingProfile)
}
private func userView(_ u: UserInfo, size: CGFloat) -> some View {
HStack {
ZStack(alignment: .topTrailing) {
ProfileImage(imageStr: u.user.image, size: size, color: Color(uiColor: .tertiarySystemGroupedBackground))
if (u.unreadCount > 0) {
userUnreadBadge(u, theme: theme).offset(x: 4, y: -4)
}
}
.padding(.trailing, 6)
NameWithBadge(Text(u.user.displayName).font(.title2), u.user.profile.localBadge, .title2)
.lineLimit(1)
}
.padding(rowPadding)
.modifier(ListRow {
switchingProfile = true
Task {
do {
try await changeActiveUserAsync_(u.user.userId, viewPwd: nil)
await MainActor.run {
switchingProfile = false
userPickerShown = false
}
} catch {
await MainActor.run {
switchingProfile = false
showAlert(
NSLocalizedString("Error switching profile!", comment: "alertTitle"),
message: String.localizedStringWithFormat(NSLocalizedString("Error: %@", comment: "alert message"), responseError(error))
)
}
}
}
})
.clipShape(sectionShape)
}
private func openSheetOnTap(_ icon: String, title: LocalizedStringKey, sheet: UserPickerSheet, showDivider: Bool = true, disabled: Bool = false) -> some View {
ZStack(alignment: .bottom) {
settingsRow(icon, color: theme.colors.secondary) {
Text(title).foregroundColor(.primary).opacity(disabled ? 0.4 : 1)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, rowPadding)
.padding(.vertical, rowVerticalPadding)
.modifier(ListRow { activeSheet = sheet })
.disabled(disabled)
if showDivider {
Divider().padding(.leading, 52)
}
}
}
}
@inline(__always)
func userUnreadBadge(_ userInfo: UserInfo, theme: AppTheme) -> some View {
UnreadBadge(
count: userInfo.unreadCount,
color: userInfo.user.showNtfs ? theme.colors.primary : theme.colors.secondary
)
}
struct UnreadBadge: View {
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
var count: Int
var color: Color
var body: some View {
let size = dynamicSize(userFont).chatInfoSize
unreadCountText(count)
.font(userFont <= .xxxLarge ? .caption : .caption2)
.foregroundColor(.white)
.padding(.horizontal, dynamicSize(userFont).unreadPadding)
.frame(minWidth: size, minHeight: size)
.background(color)
.cornerRadius(dynamicSize(userFont).unreadCorner)
}
}
struct ListRow: ViewModifier {
@Environment(\.colorScheme) private var colorScheme: ColorScheme
@State private var touchDown = false
let action: () -> Void
func body(content: Content) -> some View {
ZStack {
elevatedSecondarySystemGroupedBackground
Color(.systemGray4).opacity(touchDown ? 1 : 0)
content
TouchOverlay(touchDown: $touchDown, action: action)
}
}
var elevatedSecondarySystemGroupedBackground: Color {
switch colorScheme {
case .dark: Color(0xFF2C2C2E)
default: Color(0xFFFFFFFF)
}
}
struct TouchOverlay: UIViewRepresentable {
@Binding var touchDown: Bool
let action: () -> Void
func makeUIView(context: Context) -> TouchView {
let touchView = TouchView()
let gesture = UILongPressGestureRecognizer(
target: touchView,
action: #selector(touchView.longPress(gesture:))
)
gesture.delegate = touchView
gesture.minimumPressDuration = 0
touchView.addGestureRecognizer(gesture)
return touchView
}
func updateUIView(_ touchView: TouchView, context: Context) {
touchView.representer = self
}
class TouchView: UIView, UIGestureRecognizerDelegate {
var representer: TouchOverlay?
private var startLocation: CGPoint?
private var task: Task<Void, Never>?
@objc
func longPress(gesture: UILongPressGestureRecognizer) {
switch gesture.state {
case .began:
startLocation = gesture.location(in: nil)
task = Task {
do {
try await Task.sleep(nanoseconds: 200_000000)
await MainActor.run { representer?.touchDown = true }
} catch { }
}
case .ended:
if hitTest(gesture.location(in: self), with: nil) == self {
representer?.action()
}
task?.cancel()
representer?.touchDown = false
case .changed:
if let startLocation {
let location = gesture.location(in: nil)
let dx = location.x - startLocation.x
let dy = location.y - startLocation.y
if sqrt(pow(dx, 2) + pow(dy, 2)) > 10 { gesture.state = .failed }
}
case .cancelled, .failed:
task?.cancel()
representer?.touchDown = false
default: break
}
}
func gestureRecognizer(
_: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith: UIGestureRecognizer
) -> Bool { true }
}
}
}
struct UserPicker_Previews: PreviewProvider {
static var previews: some View {
@State var activeSheet: UserPickerSheet?
let m = ChatModel()
m.users = [UserInfo.sampleData, UserInfo.sampleData]
return UserPicker(
userPickerShown: .constant(true),
activeSheet: $activeSheet
)
.environmentObject(m)
}
}