mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-15 17:40:43 +00:00
ui: buy a badge in the app through the App Store or Google Play (#7608)
* ui: buy a badge in the app through the App Store or Google Play * core, ios, android: create the badge store purchase record before the store charges * core, ios, android: fixes from the store lane review * ios, android: show a store purchase's badge only on the profile it was credited to
This commit is contained in:
@@ -30,6 +30,7 @@ module Simplex.Chat.Badges.Types
|
||||
BadgeAlert (..),
|
||||
BadgeAlertPrice (..),
|
||||
BadgeState (..),
|
||||
BadgeStorePurchase (..),
|
||||
) where
|
||||
|
||||
import Control.Applicative ((<|>))
|
||||
@@ -282,6 +283,14 @@ data BadgeState = BadgeState
|
||||
}
|
||||
deriving (Show)
|
||||
|
||||
-- | A store purchase this profile started or presented, neither credited nor closed. Neither
|
||||
-- reference is a secret: Apple's is the transaction id, Google's is a hash of the token.
|
||||
data BadgeStorePurchase = BadgeStorePurchase
|
||||
{ invoiceId :: Maybe Text,
|
||||
transactionRef :: Maybe Text
|
||||
}
|
||||
deriving (Show)
|
||||
|
||||
instance TextEncoding BadgePurchaseStatus where
|
||||
textEncode = \case
|
||||
PSAcquiring -> "acquiring"
|
||||
@@ -413,3 +422,5 @@ $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "BIF") ''BadgeIssueFailure)
|
||||
$(JQ.deriveJSON defaultJSON ''BadgeIssueError)
|
||||
|
||||
$(JQ.deriveJSON defaultJSON ''BadgeState)
|
||||
|
||||
$(JQ.deriveJSON defaultJSON ''BadgeStorePurchase)
|
||||
|
||||
@@ -85,7 +85,7 @@ import Simplex.Messaging.Client (HostMode (..), SMPProxyFallback (..), SMPProxyM
|
||||
import qualified Simplex.Messaging.Crypto as C
|
||||
import Simplex.Chat.Badges (BadgeCredential, FileSizeLimits, LocalBadge)
|
||||
import Simplex.Chat.Badges.Service (BadgeServiceErrorCode, StatementEntry)
|
||||
import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeAlertKind, BadgeState (..))
|
||||
import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeAlertKind, BadgeState (..), BadgeStorePurchase (..))
|
||||
import Simplex.Chat.PaymentService (ServicePayment)
|
||||
import Simplex.Messaging.Crypto.BBS (BBSPublicKey)
|
||||
import Simplex.Messaging.Crypto.File (CryptoFile (..))
|
||||
@@ -660,7 +660,9 @@ data ChatCommand
|
||||
| UpdateProfileImageFromFile FilePath -- set profile image from a .png/.jpg/.jpeg file
|
||||
| AddBadge BadgeCredential -- attach an issued badge credential (testing; credential from `simplex-chat badge sign`)
|
||||
| APIRedeemBadgeCode {userId :: UserId, code :: Text} -- redeem a badge code with the configured badge service
|
||||
| APIPurchaseBadge {userId :: UserId, payment :: ServicePayment} -- redeem an App Store or Google Play purchase for a badge
|
||||
| APIPurchaseBadge {userId :: UserId, echoedInvoiceId :: Maybe Text, payment :: ServicePayment} -- redeem an App Store or Google Play purchase for a badge
|
||||
| APICreateBadgeInvoice {userId :: UserId} -- the record of a store purchase, created before the store charges; answers the id the store echoes
|
||||
| APICloseBadgeInvoice {userId :: UserId, invoiceId :: Text} -- a store purchase that came to nothing
|
||||
| APIGetBadgeState {userId :: UserId} -- the user's badges, their balances and any current alert
|
||||
| APIGetBadgeLedger {userId :: UserId, badgePurchaseId :: Int64} -- the purchase's ledger, oldest first
|
||||
-- episode is last because it is free text: it is the value that makes one occurrence of an
|
||||
@@ -871,7 +873,8 @@ data ChatResponse
|
||||
| CRServiceResponse {user :: User, responseData :: J.Object}
|
||||
| CRServiceReplyAccepted {user :: User, connectionId :: AgentConnId}
|
||||
| CRBadgeRedeemed {user :: User, redeemedBadge :: LocalBadge, newBadge :: Bool, badgeState :: Maybe BadgeState}
|
||||
| CRBadgeState {user :: User, badgeState :: Maybe BadgeState}
|
||||
| CRBadgeInvoice {user :: User, invoiceId :: Text}
|
||||
| CRBadgeState {user :: User, badgeState :: Maybe BadgeState, storePurchases :: [BadgeStorePurchase]}
|
||||
| CRBadgeLedger {user :: User, badgeLedger :: [StatementEntry]}
|
||||
| CRUserAcceptedGroupSent {user :: User, groupInfo :: GroupInfo, hostContact :: Maybe Contact}
|
||||
| CRUserDeletedMembers {user :: User, groupInfo :: GroupInfo, members :: [GroupMember], withMessages :: Bool, msgSigned :: Bool}
|
||||
|
||||
@@ -3563,11 +3563,23 @@ processChatCommand cxt nm = \case
|
||||
ShowProfile -> withUser $ \user@User {profile} -> pure $ CRUserProfile user (fromLocalProfile profile)
|
||||
AddBadge cred -> withUser $ \user -> addUserBadge user cred >> ok user
|
||||
APIRedeemBadgeCode userId codeText -> withUserId userId $ \user -> redeemBadgeCode nm user codeText
|
||||
APIPurchaseBadge userId payment -> withUserId userId $ \user -> purchaseBadge nm user payment
|
||||
APIPurchaseBadge userId echoedInvoiceId payment -> withUserId userId $ \user -> purchaseBadge nm user echoedInvoiceId payment
|
||||
APICreateBadgeInvoice userId -> withUserId userId $ \user -> do
|
||||
void badgeServiceTarget
|
||||
refuseWhileBadgeHeld user
|
||||
g <- asks random
|
||||
now <- liftIO getCurrentTime
|
||||
invoiceId <- UUID.toText <$> liftIO V4.nextRandom
|
||||
_ <- withStore' $ \db -> createBadgeStoreReceipt db g user (Just invoiceId) Nothing now
|
||||
pure $ CRBadgeInvoice user invoiceId
|
||||
APICloseBadgeInvoice userId invoiceId -> withUserId userId $ \user -> do
|
||||
now <- liftIO getCurrentTime
|
||||
withStore' $ \db -> closeBadgeStoreInvoice db user invoiceId now
|
||||
ok user
|
||||
APIGetBadgeState userId -> withUserId' userId $ \user -> do
|
||||
-- the read also signals the worker, whose results follow as CEvtBadgeChanged
|
||||
lift $ startBadgeWork user
|
||||
CRBadgeState user <$> getUserBadgeState user
|
||||
badgeStateResponse user
|
||||
APIGetBadgeLedger userId badgePurchaseId -> withUserId userId $ \user ->
|
||||
CRBadgeLedger user <$> withStore' (\db -> getBadgeLedger db user badgePurchaseId)
|
||||
APIAckBadgeAlert userId badgePurchaseId alertKind snooze episode -> withUserId userId $ \user -> do
|
||||
@@ -3576,7 +3588,7 @@ processChatCommand cxt nm = \case
|
||||
withStore' $ \db -> setBadgeAlertAcked db user badgePurchaseId alertKind episode snoozeUntil
|
||||
-- after the write, so the pass it signals arms a wake for the snooze rather than raising again
|
||||
lift $ startBadgeWork user
|
||||
CRBadgeState user <$> getUserBadgeState user
|
||||
badgeStateResponse user
|
||||
SetBotCommands commands -> withUser $ \user@User {profile} -> do
|
||||
let LocalProfile {preferences} = profile
|
||||
prefs = Just (fromMaybe emptyChatPrefs preferences :: Preferences) {commands = Just commands}
|
||||
@@ -5221,7 +5233,7 @@ presentUserBadgeToContacts user'@User {userId, profile = LocalProfile {localBadg
|
||||
redeemBadgeCode :: NetworkRequestMode -> User -> Text -> CM ChatResponse
|
||||
redeemBadgeCode nm user@User {userId} codeText = do
|
||||
code <- maybe (throwRedeemError BREInvalidCode) pure $ parseBadgeCode codeText
|
||||
sendTarget <- asks (badgeServiceAddress . config) >>= maybe (throwRedeemError BREServiceNotConfigured) pure
|
||||
sendTarget <- badgeServiceTarget
|
||||
g <- asks random
|
||||
now <- liftIO getCurrentTime
|
||||
let codeSent = badgeCodeText code
|
||||
@@ -5244,16 +5256,16 @@ redeemBadgeCode nm user@User {userId} codeText = do
|
||||
|
||||
-- | The app presents a purchase until this returns its badge, under whichever profile is active; the
|
||||
-- stash stays with the profile it was first presented under, so every retry is the signer first credited.
|
||||
purchaseBadge :: NetworkRequestMode -> User -> ServicePayment -> CM ChatResponse
|
||||
purchaseBadge nm presentingUser payment = do
|
||||
purchaseBadge :: NetworkRequestMode -> User -> Maybe Text -> ServicePayment -> CM ChatResponse
|
||||
purchaseBadge nm presentingUser echoedInvoiceId payment = do
|
||||
txRef <- maybe (throwRedeemError BREInvalidReceipt) pure $ storeTransactionRef payment
|
||||
sendTarget <- asks (badgeServiceAddress . config) >>= maybe (throwRedeemError BREServiceNotConfigured) pure
|
||||
sendTarget <- badgeServiceTarget
|
||||
g <- asks random
|
||||
now <- liftIO getCurrentTime
|
||||
user@User {userId} <- withStore $ \db -> liftIO (getBadgeStoreReceiptUserId db txRef) >>= maybe (pure presentingUser) (getUser db)
|
||||
user@User {userId} <- withStore $ \db -> liftIO (attachBadgeStoreReceipt db echoedInvoiceId txRef) >>= maybe (pure presentingUser) (getUser db)
|
||||
(present_, purchased) <- withEntityLock "badgePurchase" (CLBadgeUser userId) $ do
|
||||
stash_ <- withStore' $ \db -> getBadgeStoreReceipt db user txRef
|
||||
stash@BadgeStash {masterKey} <- stashBadgeKeys user stash_ $ \db -> createBadgeStoreReceipt db g user txRef now
|
||||
stash@BadgeStash {masterKey} <- stashBadgeKeys user stash_ $ \db -> createBadgeStoreReceipt db g user echoedInvoiceId (Just txRef) now
|
||||
requestStashedBadge nm user sendTarget stash BSCPurchaseBadge {masterKey, payment, upgrade = Nothing} terminalReceiptError
|
||||
-- outside the badge lock: the chat lock must not be taken under it
|
||||
mapM_ presentUserBadgeToContacts present_
|
||||
@@ -5278,9 +5290,15 @@ storeTransactionRef = \case
|
||||
stashBadgeKeys :: User -> Maybe BadgeStash -> (DB.Connection -> IO BadgeStash) -> CM BadgeStash
|
||||
stashBadgeKeys user stash_ createStash = do
|
||||
replaying <- maybe (pure False) (\s -> withStore' $ \db -> isJust <$> getStashBadgePurchase db s) stash_
|
||||
unless replaying $ whenM (withStore' (`userHasBadge` user)) $ throwRedeemError BREBadgeActive
|
||||
unless replaying $ refuseWhileBadgeHeld user
|
||||
maybe (withStore' createStash) pure stash_
|
||||
|
||||
badgeServiceTarget :: CM (ConnectTarget 'CMContact)
|
||||
badgeServiceTarget = asks (badgeServiceAddress . config) >>= maybe (throwRedeemError BREServiceNotConfigured) pure
|
||||
|
||||
refuseWhileBadgeHeld :: User -> CM ()
|
||||
refuseWhileBadgeHeld user = whenM (withStore' (`userHasBadge` user)) $ throwRedeemError BREBadgeActive
|
||||
|
||||
-- | A refusal for which stashDead holds drops the stash; any other, and a timeout, keep it.
|
||||
requestStashedBadge :: NetworkRequestMode -> User -> ConnectTarget 'CMContact -> BadgeStash -> BadgeServiceCommand -> (BadgeServiceErrorCode -> Bool) -> CM (Maybe User, ChatResponse)
|
||||
requestStashedBadge nm user sendTarget stash@BadgeStash {purchaseKey, purchasePrivKey} request stashDead = do
|
||||
@@ -5480,6 +5498,12 @@ emitBadgeAlert user emitted p@UserBadgePurchase {alertSnoozeUntil} shownCred now
|
||||
raised <- atomically $ stateTVar emitted (,occurrence)
|
||||
when (raised /= occurrence) $ toView $ CEvtBadgeAlert user alert
|
||||
|
||||
-- | Scoped to one profile, so another profile's store purchase, which may be hidden, never shows under it.
|
||||
badgeStateResponse :: User -> CM ChatResponse
|
||||
badgeStateResponse user = do
|
||||
badgeState <- getUserBadgeState user
|
||||
CRBadgeState user badgeState <$> withStore' (`getOpenBadgeStorePurchases` user)
|
||||
|
||||
-- | Read from stored rows alone; the worker's results follow as CEvtBadgeChanged.
|
||||
getUserBadgeState :: User -> CM (Maybe BadgeState)
|
||||
getUserBadgeState user = do
|
||||
@@ -6150,7 +6174,9 @@ chatCommandP =
|
||||
"/_reject " *> (APIRejectContact <$> A.decimal <*> (" notify=" *> onOffP <|> pure False)),
|
||||
"/_service_request " *> (APISendServiceRequest <$> A.decimal <* A.space <*> strP <*> optional (" timeout=" *> (realToFrac <$> A.double)) <*> optional (" sign_key=" *> strP) <* A.space <*> jsonP),
|
||||
"/_redeem_badge_code " *> (APIRedeemBadgeCode <$> A.decimal <* A.space <*> textP),
|
||||
"/_badge purchase " *> (APIPurchaseBadge <$> A.decimal <* A.space <*> jsonP),
|
||||
"/_badge purchase " *> (APIPurchaseBadge <$> A.decimal <*> optional (" invoice=" *> (safeDecodeUtf8 <$> A.takeTill (== ' '))) <* A.space <*> jsonP),
|
||||
"/_badge invoice close " *> (APICloseBadgeInvoice <$> A.decimal <* A.space <*> textP),
|
||||
"/_badge invoice " *> (APICreateBadgeInvoice <$> A.decimal),
|
||||
"/_badge state " *> (APIGetBadgeState <$> A.decimal),
|
||||
"/_badge ledger " *> (APIGetBadgeLedger <$> A.decimal <* A.space <*> A.decimal),
|
||||
"/_badge ack " *> (APIAckBadgeAlert <$> A.decimal <* A.space <*> A.decimal <* A.space <*> badgeAlertKindP <* A.space <*> onOffP <* A.space <*> textP),
|
||||
|
||||
@@ -21,6 +21,9 @@ module Simplex.Chat.Store.Badges
|
||||
getBadgeCodeRedemption,
|
||||
createBadgeCodeRedemption,
|
||||
getBadgeStoreReceiptUserId,
|
||||
attachBadgeStoreReceipt,
|
||||
closeBadgeStoreInvoice,
|
||||
getOpenBadgeStorePurchases,
|
||||
getBadgeStoreReceipt,
|
||||
createBadgeStoreReceipt,
|
||||
deleteBadgeStash,
|
||||
@@ -36,6 +39,7 @@ module Simplex.Chat.Store.Badges
|
||||
where
|
||||
|
||||
import Control.Concurrent.STM (TVar, atomically)
|
||||
import Control.Monad (forM, forM_, join)
|
||||
import Crypto.Random (ChaChaDRG)
|
||||
import qualified Data.Aeson as J
|
||||
import Data.ByteString.Char8 (ByteString)
|
||||
@@ -47,7 +51,7 @@ import Data.Time.Clock (UTCTime)
|
||||
import Simplex.Chat.Badges
|
||||
import Simplex.Chat.Badges.Ledger
|
||||
import Simplex.Chat.Badges.Service (StatementCreditType (..), StatementDebitType (..), StatementEntry (..), StatementEntryType (..))
|
||||
import Simplex.Chat.Badges.Types (BadgeAlertKind, BadgeIssueError (..), BadgeIssueFailure, BadgePurchaseStatus (..))
|
||||
import Simplex.Chat.Badges.Types (BadgeAlertKind, BadgeIssueError (..), BadgeIssueFailure, BadgePurchaseStatus (..), BadgeStorePurchase (..))
|
||||
import Simplex.Chat.Store.Shared (insertedRowId)
|
||||
import Simplex.Chat.Types
|
||||
import Simplex.Messaging.Agent.Protocol (UserId)
|
||||
@@ -114,6 +118,47 @@ getBadgeStoreReceiptUserId db StoreTransactionRef {provider, transactionRef} =
|
||||
maybeFirstRow fromOnly $
|
||||
DB.query db "SELECT user_id FROM badge_store_receipts WHERE provider = ? AND transaction_ref = ?" (provider, transactionRef)
|
||||
|
||||
-- | The profile a receipt belongs to, attaching it to the record created when Buy was tapped. A
|
||||
-- transaction already attached stays with its record, whose keys the service may have credited.
|
||||
attachBadgeStoreReceipt :: DB.Connection -> Maybe Text -> StoreTransactionRef -> IO (Maybe UserId)
|
||||
attachBadgeStoreReceipt db invoiceId_ txRef@StoreTransactionRef {provider, transactionRef} =
|
||||
getBadgeStoreReceiptUserId db txRef >>= \case
|
||||
Just userId -> pure $ Just userId
|
||||
Nothing -> fmap join . forM invoiceId_ $ \invoiceId -> do
|
||||
userId_ <-
|
||||
maybeFirstRow fromOnly $
|
||||
DB.query db "SELECT user_id FROM badge_store_receipts WHERE invoice_id = ? AND transaction_ref IS NULL" (Only invoiceId)
|
||||
-- a closed record is reopened: new keys for a paid transaction would be refused as receipt_used,
|
||||
-- and the badge the old keys were credited with would be lost
|
||||
forM_ userId_ $ \_ ->
|
||||
DB.execute
|
||||
db
|
||||
"UPDATE badge_store_receipts SET provider = ?, transaction_ref = ?, closed_at = NULL WHERE invoice_id = ?"
|
||||
(provider, transactionRef, invoiceId)
|
||||
pure userId_
|
||||
|
||||
-- | Only a record no receipt has reached can be closed.
|
||||
closeBadgeStoreInvoice :: DB.Connection -> User -> Text -> UTCTime -> IO ()
|
||||
closeBadgeStoreInvoice db User {userId} invoiceId now =
|
||||
DB.execute
|
||||
db
|
||||
"UPDATE badge_store_receipts SET closed_at = ? WHERE user_id = ? AND invoice_id = ? AND transaction_ref IS NULL"
|
||||
(now, userId, invoiceId)
|
||||
|
||||
getOpenBadgeStorePurchases :: DB.Connection -> User -> IO [BadgeStorePurchase]
|
||||
getOpenBadgeStorePurchases db User {userId} =
|
||||
map (uncurry BadgeStorePurchase)
|
||||
<$> DB.query
|
||||
db
|
||||
[sql|
|
||||
SELECT r.invoice_id, r.transaction_ref
|
||||
FROM badge_store_receipts r
|
||||
WHERE r.user_id = ? AND r.closed_at IS NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM badge_purchases p WHERE p.badge_store_receipt_id = r.badge_store_receipt_id)
|
||||
ORDER BY r.badge_store_receipt_id
|
||||
|]
|
||||
(Only userId)
|
||||
|
||||
getBadgeStoreReceipt :: DB.Connection -> User -> StoreTransactionRef -> IO (Maybe BadgeStash)
|
||||
getBadgeStoreReceipt db User {userId} StoreTransactionRef {provider, transactionRef} =
|
||||
maybeFirstRow (toBadgeStash BSRStoreReceipt) $
|
||||
@@ -126,17 +171,24 @@ getBadgeStoreReceipt db User {userId} StoreTransactionRef {provider, transaction
|
||||
|]
|
||||
(userId, provider, transactionRef)
|
||||
|
||||
createBadgeStoreReceipt :: DB.Connection -> TVar ChaChaDRG -> User -> StoreTransactionRef -> UTCTime -> IO BadgeStash
|
||||
createBadgeStoreReceipt db g User {userId} StoreTransactionRef {provider, transactionRef} now = do
|
||||
-- | An invoice id another record already holds is not repeated: the record is then keyed by its transaction alone.
|
||||
createBadgeStoreReceipt :: DB.Connection -> TVar ChaChaDRG -> User -> Maybe Text -> Maybe StoreTransactionRef -> UTCTime -> IO BadgeStash
|
||||
createBadgeStoreReceipt db g User {userId} invoiceId_ txRef_ now = do
|
||||
(purchaseKey, purchasePrivKey) <- atomically $ C.generateKeyPair g
|
||||
masterKey@(BadgeMasterKey mk) <- generateMasterKey g
|
||||
invoiceId' <- fmap join . forM invoiceId_ $ \invoiceId -> do
|
||||
taken <- DB.query db "SELECT badge_store_receipt_id FROM badge_store_receipts WHERE invoice_id = ?" (Only invoiceId) :: IO [Only Int64]
|
||||
pure $ if null taken then Just invoiceId else Nothing
|
||||
let (provider_, transactionRef_) = case txRef_ of
|
||||
Just StoreTransactionRef {provider, transactionRef} -> (Just provider, Just transactionRef)
|
||||
Nothing -> (Nothing, Nothing)
|
||||
DB.execute
|
||||
db
|
||||
[sql|
|
||||
INSERT INTO badge_store_receipts (user_id, provider, transaction_ref, purchase_key, purchase_priv_key, master_key, created_at)
|
||||
VALUES (?,?,?,?,?,?,?)
|
||||
INSERT INTO badge_store_receipts (user_id, invoice_id, provider, transaction_ref, purchase_key, purchase_priv_key, master_key, created_at)
|
||||
VALUES (?,?,?,?,?,?,?,?)
|
||||
|]
|
||||
(userId, provider, transactionRef, purchaseKey, purchasePrivKey, Binary mk, now)
|
||||
((userId, invoiceId', provider_, transactionRef_) :. (purchaseKey, purchasePrivKey, Binary mk, now))
|
||||
storeReceiptId <- insertedRowId db
|
||||
pure BadgeStash {stashRef = BSRStoreReceipt storeReceiptId, purchaseKey, purchasePrivKey, masterKey}
|
||||
|
||||
|
||||
@@ -12,12 +12,14 @@ m20260925_badge_store_receipts =
|
||||
CREATE TABLE badge_store_receipts(
|
||||
badge_store_receipt_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users ON DELETE CASCADE,
|
||||
provider TEXT NOT NULL,
|
||||
transaction_ref TEXT NOT NULL,
|
||||
invoice_id TEXT UNIQUE,
|
||||
provider TEXT,
|
||||
transaction_ref TEXT,
|
||||
purchase_key BYTEA NOT NULL,
|
||||
purchase_priv_key BYTEA NOT NULL,
|
||||
master_key BYTEA NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
closed_at TIMESTAMPTZ,
|
||||
UNIQUE(provider, transaction_ref)
|
||||
);
|
||||
|
||||
|
||||
@@ -11,12 +11,14 @@ m20260925_badge_store_receipts =
|
||||
CREATE TABLE badge_store_receipts(
|
||||
badge_store_receipt_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE,
|
||||
provider TEXT NOT NULL,
|
||||
transaction_ref TEXT NOT NULL,
|
||||
invoice_id TEXT UNIQUE,
|
||||
provider TEXT,
|
||||
transaction_ref TEXT,
|
||||
purchase_key BLOB NOT NULL,
|
||||
purchase_priv_key BLOB NOT NULL,
|
||||
master_key BLOB NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
closed_at TEXT,
|
||||
UNIQUE(provider, transaction_ref)
|
||||
) STRICT;
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ import Simplex.Chat.Markdown
|
||||
import Simplex.Chat.Badges (BadgeInfo (..), BadgeStatus (..), BadgeType (..), LocalBadge, localBadgeInfo, localBadgeStatus)
|
||||
import Simplex.Chat.Badges.Ledger (creditTypeTag, debitTypeTag)
|
||||
import Simplex.Chat.Badges.Service (StatementEntry (..), StatementEntryType (..))
|
||||
import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeIssueError (..), BadgeState (..))
|
||||
import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeIssueError (..), BadgeState (..), BadgeStorePurchase (..))
|
||||
import Simplex.Chat.Messages hiding (NewChatItem (..))
|
||||
import Simplex.Chat.Messages.CIContent
|
||||
import Simplex.Chat.Operators
|
||||
@@ -193,7 +193,8 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, showFullLinks, te
|
||||
CRServiceReplyAccepted u (AgentConnId cId) -> ttyUser u [plain $ "service reply accepted, connection id: " <> safeDecodeUtf8 (strEncode cId)]
|
||||
-- the badge is only shown when it is the one now on the profile; a replayed code's badge may not be
|
||||
CRBadgeRedeemed u badge newBadge _ -> ttyUser u $ if newBadge then "badge redeemed" : viewContactBadge (Just badge) else ["badge already redeemed"]
|
||||
CRBadgeState u st -> ttyUser u $ viewUserBadgeState st
|
||||
CRBadgeInvoice u invoiceId -> ttyUser u ["badge invoice: " <> plain invoiceId]
|
||||
CRBadgeState u st storePurchases -> ttyUser u $ viewUserBadgeState st <> map viewBadgeStorePurchase storePurchases
|
||||
CRBadgeLedger u entries -> ttyUser u $ viewBadgeLedger entries
|
||||
CRGroupCreated u g -> ttyUser u $ viewGroupCreated g testView
|
||||
CRPublicGroupCreated u g _groupLink _relays -> ttyUser u $ viewGroupCreated g testView
|
||||
@@ -1863,6 +1864,10 @@ viewBadgeIssueError BadgeIssueError {failedSince, lastAttemptAt, reason} =
|
||||
viewBadgeAlert :: BadgeAlert -> [StyledString]
|
||||
viewBadgeAlert BadgeAlert {kind, date} = [plain $ "badge alert: " <> textEncode kind <> " " <> day date]
|
||||
|
||||
viewBadgeStorePurchase :: BadgeStorePurchase -> StyledString
|
||||
viewBadgeStorePurchase BadgeStorePurchase {invoiceId, transactionRef} =
|
||||
plain $ "store purchase open: invoice " <> fromMaybe "none" invoiceId <> maybe "" (", transaction " <>) transactionRef
|
||||
|
||||
viewBadgeLedger :: [StatementEntry] -> [StyledString]
|
||||
viewBadgeLedger [] = ["no ledger entries"]
|
||||
viewBadgeLedger entries = map viewEntry entries
|
||||
|
||||
Reference in New Issue
Block a user