github.com/keybase/client/go@v0.0.0-20240309051027-028f7c731f8b/chat/journey_card_manager.go (about)

     1  package chat
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"log"
     7  	"sort"
     8  	"strings"
     9  	"sync"
    10  	"time"
    11  
    12  	"github.com/davecgh/go-spew/spew"
    13  	lru "github.com/hashicorp/golang-lru"
    14  	"github.com/keybase/client/go/chat/globals"
    15  	"github.com/keybase/client/go/chat/storage"
    16  	"github.com/keybase/client/go/chat/types"
    17  	"github.com/keybase/client/go/chat/utils"
    18  	"github.com/keybase/client/go/encrypteddb"
    19  	"github.com/keybase/client/go/libkb"
    20  	"github.com/keybase/client/go/protocol/chat1"
    21  	"github.com/keybase/client/go/protocol/gregor1"
    22  	"github.com/keybase/client/go/protocol/keybase1"
    23  )
    24  
    25  const cardSinceJoinedCap = time.Hour * 24 * 7 * 4
    26  
    27  // JourneyCardManager handles user switching and proxies to the active JourneyCardManagerSingleUser.
    28  type JourneyCardManager struct {
    29  	globals.Contextified
    30  	utils.DebugLabeler
    31  	switchLock sync.Mutex
    32  	m          *JourneyCardManagerSingleUser
    33  	ri         func() chat1.RemoteInterface
    34  }
    35  
    36  var _ (types.JourneyCardManager) = (*JourneyCardManager)(nil)
    37  
    38  func NewJourneyCardManager(g *globals.Context, ri func() chat1.RemoteInterface) *JourneyCardManager {
    39  	return &JourneyCardManager{
    40  		Contextified: globals.NewContextified(g),
    41  		ri:           ri,
    42  		DebugLabeler: utils.NewDebugLabeler(g.ExternalG(), "JourneyCardManager", false),
    43  	}
    44  }
    45  
    46  func (j *JourneyCardManager) get(ctx context.Context, uid gregor1.UID) (*JourneyCardManagerSingleUser, error) {
    47  	if uid.IsNil() {
    48  		return nil, fmt.Errorf("missing uid")
    49  	}
    50  	err := libkb.AcquireWithContextAndTimeout(ctx, &j.switchLock, 10*time.Second)
    51  	if err != nil {
    52  		return nil, fmt.Errorf("JourneyCardManager switchLock error: %v", err)
    53  	}
    54  	defer j.switchLock.Unlock()
    55  	if j.m != nil && !j.m.uid.Eq(uid) {
    56  		j.m = nil
    57  	}
    58  	if j.m == nil {
    59  		j.m = NewJourneyCardManagerSingleUser(j.G(), j.ri, uid)
    60  		j.Debug(ctx, "switched to uid:%v", uid)
    61  	}
    62  	return j.m, nil
    63  }
    64  
    65  func (j *JourneyCardManager) PickCard(ctx context.Context, uid gregor1.UID,
    66  	convID chat1.ConversationID,
    67  	convLocalOptional *chat1.ConversationLocal,
    68  	thread *chat1.ThreadView,
    69  ) (*chat1.MessageUnboxedJourneycard, error) {
    70  	start := j.G().GetClock().Now()
    71  	defer func() {
    72  		duration := j.G().GetClock().Since(start)
    73  		if duration > time.Millisecond*200 {
    74  			j.Debug(ctx, "PickCard took %s", duration)
    75  		}
    76  	}()
    77  	js, err := j.get(ctx, uid)
    78  	if err != nil {
    79  		return nil, err
    80  	}
    81  	return js.PickCard(ctx, convID, convLocalOptional, thread)
    82  }
    83  
    84  func (j *JourneyCardManager) TimeTravel(ctx context.Context, uid gregor1.UID, duration time.Duration) (int, int, error) {
    85  	js, err := j.get(ctx, uid)
    86  	if err != nil {
    87  		return 0, 0, err
    88  	}
    89  	return js.TimeTravel(ctx, duration)
    90  }
    91  
    92  func (j *JourneyCardManager) ResetAllConvs(ctx context.Context, uid gregor1.UID) (err error) {
    93  	js, err := j.get(ctx, uid)
    94  	if err != nil {
    95  		return err
    96  	}
    97  	return js.ResetAllConvs(ctx)
    98  }
    99  
   100  func (j *JourneyCardManager) DebugState(ctx context.Context, uid gregor1.UID, teamID keybase1.TeamID) (summary string, err error) {
   101  	js, err := j.get(ctx, uid)
   102  	if err != nil {
   103  		return "", err
   104  	}
   105  	return js.DebugState(ctx, teamID)
   106  }
   107  
   108  func (j *JourneyCardManager) SentMessage(ctx context.Context, uid gregor1.UID, teamID keybase1.TeamID, convID chat1.ConversationID) {
   109  	js, err := j.get(ctx, uid)
   110  	if err != nil {
   111  		j.Debug(ctx, "SentMessage error: %v", err)
   112  		return
   113  	}
   114  	js.SentMessage(ctx, teamID, convID)
   115  }
   116  
   117  func (j *JourneyCardManager) Dismiss(ctx context.Context, uid gregor1.UID, teamID keybase1.TeamID, convID chat1.ConversationID, jcType chat1.JourneycardType) {
   118  	js, err := j.get(ctx, uid)
   119  	if err != nil {
   120  		j.Debug(ctx, "SentMessage error: %v", err)
   121  		return
   122  	}
   123  	js.Dismiss(ctx, teamID, convID, jcType)
   124  }
   125  
   126  func (j *JourneyCardManager) OnDbNuke(mctx libkb.MetaContext) error {
   127  	return j.clear(mctx.Ctx())
   128  }
   129  
   130  func (j *JourneyCardManager) Start(ctx context.Context, uid gregor1.UID) {
   131  	var err error
   132  	defer j.G().CTrace(ctx, "JourneyCardManager.Start", nil)()
   133  	_, err = j.get(ctx, uid)
   134  	_ = err // ignore error
   135  }
   136  
   137  func (j *JourneyCardManager) Stop(ctx context.Context) chan struct{} {
   138  	var err error
   139  	defer j.G().CTrace(ctx, "JourneyCardManager.Stop", nil)()
   140  	err = j.clear(ctx)
   141  	_ = err // ignore error
   142  	ch := make(chan struct{})
   143  	close(ch)
   144  	return ch
   145  }
   146  
   147  func (j *JourneyCardManager) clear(ctx context.Context) error {
   148  	err := libkb.AcquireWithContextAndTimeout(ctx, &j.switchLock, 10*time.Second)
   149  	if err != nil {
   150  		return fmt.Errorf("JourneyCardManager switchLock error: %v", err)
   151  	}
   152  	defer j.switchLock.Unlock()
   153  	j.m = nil
   154  	return nil
   155  }
   156  
   157  type JourneyCardManagerSingleUser struct {
   158  	globals.Contextified
   159  	ri func() chat1.RemoteInterface
   160  	utils.DebugLabeler
   161  	uid         gregor1.UID // Each instance of JourneyCardManagerSingleUser works only for a single fixed uid.
   162  	storageLock sync.Mutex
   163  	lru         *lru.Cache
   164  	encryptedDB *encrypteddb.EncryptedDB
   165  }
   166  
   167  type logFn func(ctx context.Context, format string, args ...interface{})
   168  
   169  func NewJourneyCardManagerSingleUser(g *globals.Context, ri func() chat1.RemoteInterface, uid gregor1.UID) *JourneyCardManagerSingleUser {
   170  	lru, err := lru.New(200)
   171  	if err != nil {
   172  		// lru.New only panics if size <= 0
   173  		log.Panicf("Could not create lru cache: %v", err)
   174  	}
   175  	dbFn := func(g *libkb.GlobalContext) *libkb.JSONLocalDb {
   176  		return g.LocalChatDb
   177  	}
   178  	keyFn := func(ctx context.Context) ([32]byte, error) {
   179  		return storage.GetSecretBoxKeyWithUID(ctx, g.ExternalG(), uid)
   180  	}
   181  	return &JourneyCardManagerSingleUser{
   182  		Contextified: globals.NewContextified(g),
   183  		ri:           ri,
   184  		DebugLabeler: utils.NewDebugLabeler(g.ExternalG(), "JourneyCardManager", false),
   185  		uid:          uid,
   186  		lru:          lru,
   187  		encryptedDB:  encrypteddb.New(g.ExternalG(), dbFn, keyFn),
   188  	}
   189  }
   190  
   191  func (cc *JourneyCardManagerSingleUser) checkFeature(ctx context.Context) (enabled bool) {
   192  	if cc.G().GetEnv().GetDebugJourneycard() {
   193  		return true
   194  	}
   195  	if cc.G().Env.GetFeatureFlags().HasFeature(libkb.FeatureJourneycard) {
   196  		return true
   197  	}
   198  	// G.FeatureFlags seems like the kind of system that might hang on a bad network.
   199  	// PickCard is supposed to be lightning fast, so impose a timeout on FeatureFlags.
   200  	var err error
   201  	type enabledAndError struct {
   202  		enabled bool
   203  		err     error
   204  	}
   205  	ret := make(chan enabledAndError)
   206  	go func() {
   207  		enabled, err = cc.G().FeatureFlags.EnabledWithError(cc.MetaContext(ctx), libkb.FeatureJourneycard)
   208  		ret <- enabledAndError{enabled: enabled, err: err}
   209  	}()
   210  
   211  	select {
   212  	case <-time.After(100 * time.Millisecond):
   213  		cc.Debug(ctx, "JourneyCardManagerSingleUser#checkFeature timed out: returning false")
   214  		return false
   215  	case enabledAndErrorRet := <-ret:
   216  		if enabledAndErrorRet.err != nil {
   217  			cc.Debug(ctx, "JourneyCardManagerSingleUser#checkFeature errored out (returning false): %v", err)
   218  			return false
   219  		}
   220  		enabled = enabledAndErrorRet.enabled
   221  		cc.Debug(ctx, "JourneyCardManagerSingleUser#checkFeature succeeded: %v", enabled)
   222  		return enabled
   223  	}
   224  }
   225  
   226  // Choose a journey card to show in the conversation.
   227  // Called by postProcessThread so keep it snappy.
   228  func (cc *JourneyCardManagerSingleUser) PickCard(ctx context.Context,
   229  	convID chat1.ConversationID,
   230  	convLocalOptional *chat1.ConversationLocal,
   231  	thread *chat1.ThreadView,
   232  ) (*chat1.MessageUnboxedJourneycard, error) {
   233  	debug := cc.checkFeature(ctx)
   234  	// For now "debug" doesn't mean much. Everything is logged. After more real world experience
   235  	// this can be used to reduce the amount of logging.
   236  	if !debug {
   237  		// Journey cards are gated by either client-side flag KEYBASE_DEBUG_JOURNEYCARD or server-driven flag 'journeycard'.
   238  		return nil, nil
   239  	}
   240  	debugDebug := func(ctx context.Context, format string, args ...interface{}) {
   241  		if debug {
   242  			cc.Debug(ctx, format, args...)
   243  		}
   244  	}
   245  
   246  	var convInner convForJourneycardInner
   247  	var untrustedTeamRole keybase1.TeamRole
   248  	var tlfID chat1.TLFID
   249  	var welcomeEligible bool
   250  	var cannotWrite bool
   251  	if convLocalOptional != nil {
   252  		convInner = convLocalOptional
   253  		tlfID = convLocalOptional.Info.Triple.Tlfid
   254  		untrustedTeamRole = convLocalOptional.ReaderInfo.UntrustedTeamRole
   255  		if convLocalOptional.ReaderInfo.Journeycard != nil {
   256  			welcomeEligible = convLocalOptional.ReaderInfo.Journeycard.WelcomeEligible
   257  			if convInner.GetTopicName() == globals.DefaultTeamTopic {
   258  				debugDebug(ctx, "welcomeEligible: convLocalOptional has ReaderInfo.Journeycard: %v", welcomeEligible)
   259  			}
   260  		}
   261  		cannotWrite = convLocalOptional.CannotWrite()
   262  	} else {
   263  		convFromCache, err := utils.GetUnverifiedConv(ctx, cc.G(), cc.uid, convID, types.InboxSourceDataSourceLocalOnly)
   264  		if err != nil {
   265  			return nil, err
   266  		}
   267  		if convFromCache.LocalMetadata == nil {
   268  			// LocalMetadata is needed to get topicName.
   269  			return nil, fmt.Errorf("conv LocalMetadata not found")
   270  		}
   271  		convInner = convFromCache
   272  		tlfID = convFromCache.Conv.Metadata.IdTriple.Tlfid
   273  		if convFromCache.Conv.ReaderInfo != nil {
   274  			untrustedTeamRole = convFromCache.Conv.ReaderInfo.UntrustedTeamRole
   275  			if convFromCache.Conv.ReaderInfo.Journeycard != nil {
   276  				welcomeEligible = convFromCache.Conv.ReaderInfo.Journeycard.WelcomeEligible
   277  				if convInner.GetTopicName() == globals.DefaultTeamTopic {
   278  					debugDebug(ctx, "welcomeEligible: convFromCache has ReaderInfo.Journeycard: %v", welcomeEligible)
   279  				}
   280  			}
   281  			if convFromCache.Conv.ConvSettings != nil && convFromCache.Conv.ConvSettings.MinWriterRoleInfo != nil {
   282  				cannotWrite = untrustedTeamRole.IsOrAbove(convFromCache.Conv.ConvSettings.MinWriterRoleInfo.Role)
   283  			}
   284  		}
   285  	}
   286  
   287  	conv := convForJourneycard{
   288  		convForJourneycardInner: convInner,
   289  		ConvID:                  convID,
   290  		IsGeneralChannel:        convInner.GetTopicName() == globals.DefaultTeamTopic,
   291  		UntrustedTeamRole:       untrustedTeamRole,
   292  		TlfID:                   tlfID,
   293  		// TeamID is filled a little later on
   294  		WelcomeEligible: welcomeEligible,
   295  		CannotWrite:     cannotWrite,
   296  	}
   297  
   298  	if !(conv.GetTopicType() == chat1.TopicType_CHAT &&
   299  		conv.GetMembersType() == chat1.ConversationMembersType_TEAM) {
   300  		// Cards only exist in team chats.
   301  		cc.Debug(ctx, "conv not eligible for card: topicType:%v membersType:%v general:%v",
   302  			conv.GetTopicType(), conv.GetMembersType(), conv.GetTopicName() == globals.DefaultTeamTopic)
   303  		return nil, nil
   304  	}
   305  
   306  	teamID, err := keybase1.TeamIDFromString(tlfID.String())
   307  	if err != nil {
   308  		return nil, err
   309  	}
   310  	conv.TeamID = teamID
   311  
   312  	if len(thread.Messages) == 0 {
   313  		cc.Debug(ctx, "skipping empty page")
   314  		return nil, nil
   315  	}
   316  
   317  	jcd, err := cc.getTeamData(ctx, teamID)
   318  	if err != nil {
   319  		return nil, err
   320  	}
   321  
   322  	makeCard := func(cardType chat1.JourneycardType, highlightMsgID chat1.MessageID, preferSavedPosition bool) (*chat1.MessageUnboxedJourneycard, error) {
   323  		// preferSavedPosition : If true, the card stays in the position it was previously seen. If false, the card goes at the bottom.
   324  		var pos *journeyCardPosition
   325  		if preferSavedPosition {
   326  			pos = jcd.Convs[convID.ConvIDStr()].Positions[cardType]
   327  		}
   328  		if pos == nil {
   329  			// Pick a message to use as the base for a frontend ordinal.
   330  			prevID := conv.MaxVisibleMsgID()
   331  			if prevID == 0 {
   332  				cc.Debug(ctx, "no message found to use as base for ordinal")
   333  				return nil, nil
   334  			}
   335  			pos = &journeyCardPosition{
   336  				PrevID: prevID,
   337  			}
   338  			go cc.savePosition(globals.BackgroundChatCtx(ctx, cc.G()), teamID, convID, cardType, *pos)
   339  		} else {
   340  			var foundPrev bool
   341  			for _, msg := range thread.Messages {
   342  				if msg.GetMessageID() == pos.PrevID {
   343  					foundPrev = true
   344  					break
   345  				}
   346  			}
   347  			// If the message that is being used as a prev is not found, omit the card.
   348  			// So that the card isn't presented at the edge of a far away page.
   349  			if !foundPrev {
   350  				cc.Debug(ctx, "omitting card missing prev: %v %v", pos.PrevID, cardType)
   351  				return nil, nil
   352  			}
   353  		}
   354  		ordinal := 1 // Won't conflict with outbox messages since they are all <= outboxOrdinalStart.
   355  		cc.Debug(ctx, "makeCard -> prevID:%v cardType:%v jcdCtime:%v", pos.PrevID, cardType, jcd.Ctime.Time())
   356  		res := chat1.MessageUnboxedJourneycard{
   357  			PrevID:         pos.PrevID,
   358  			Ordinal:        ordinal,
   359  			CardType:       cardType,
   360  			HighlightMsgID: highlightMsgID,
   361  		}
   362  		if cardType == chat1.JourneycardType_ADD_PEOPLE {
   363  			res.OpenTeam, err = cc.isOpenTeam(ctx, conv)
   364  			if err != nil {
   365  				cc.Debug(ctx, "isOpenTeam error: %v", err)
   366  			}
   367  		}
   368  		return &res, nil
   369  	}
   370  
   371  	if debug {
   372  		// for testing, do special stuff based on channel name:
   373  		switch conv.GetTopicName() {
   374  		case "kb_cards_0_kb":
   375  			return makeCard(chat1.JourneycardType_WELCOME, 0, false)
   376  		case "kb_cards_1_kb":
   377  			return makeCard(chat1.JourneycardType_POPULAR_CHANNELS, 0, false)
   378  		case "kb_cards_2_kb":
   379  			return makeCard(chat1.JourneycardType_ADD_PEOPLE, 0, false)
   380  		case "kb_cards_3_kb":
   381  			return makeCard(chat1.JourneycardType_CREATE_CHANNELS, 0, false)
   382  		case "kb_cards_4_kb":
   383  			return makeCard(chat1.JourneycardType_MSG_ATTENTION, 3, false)
   384  		case "kb_cards_6_kb":
   385  			return makeCard(chat1.JourneycardType_CHANNEL_INACTIVE, 0, false)
   386  		case "kb_cards_7_kb":
   387  			return makeCard(chat1.JourneycardType_MSG_NO_ANSWER, 0, false)
   388  		}
   389  	}
   390  
   391  	linearCardOrder := []chat1.JourneycardType{
   392  		chat1.JourneycardType_WELCOME,          // 1 on design
   393  		chat1.JourneycardType_POPULAR_CHANNELS, // 2 on design
   394  		chat1.JourneycardType_ADD_PEOPLE,       // 3 on design
   395  		chat1.JourneycardType_CREATE_CHANNELS,  // 4 on design
   396  		chat1.JourneycardType_MSG_ATTENTION,    // 5 on design
   397  	}
   398  
   399  	looseCardOrder := []chat1.JourneycardType{
   400  		chat1.JourneycardType_CHANNEL_INACTIVE, // B on design
   401  		chat1.JourneycardType_MSG_NO_ANSWER,    // C on design
   402  	}
   403  
   404  	type cardCondition func(context.Context) bool
   405  	cardConditionTODO := func(ctx context.Context) bool { return false }
   406  	cardConditions := map[chat1.JourneycardType]cardCondition{
   407  		chat1.JourneycardType_WELCOME:          func(ctx context.Context) bool { return cc.cardWelcome(ctx, convID, conv, jcd, debugDebug) },
   408  		chat1.JourneycardType_POPULAR_CHANNELS: func(ctx context.Context) bool { return cc.cardPopularChannels(ctx, conv, jcd, debugDebug) },
   409  		chat1.JourneycardType_ADD_PEOPLE:       func(ctx context.Context) bool { return cc.cardAddPeople(ctx, conv, jcd, debugDebug) },
   410  		chat1.JourneycardType_CREATE_CHANNELS:  func(ctx context.Context) bool { return cc.cardCreateChannels(ctx, conv, jcd, debugDebug) },
   411  		chat1.JourneycardType_MSG_ATTENTION:    cardConditionTODO,
   412  		chat1.JourneycardType_CHANNEL_INACTIVE: func(ctx context.Context) bool { return cc.cardChannelInactive(ctx, conv, jcd, thread, debugDebug) },
   413  		chat1.JourneycardType_MSG_NO_ANSWER:    func(ctx context.Context) bool { return cc.cardMsgNoAnswer(ctx, conv, jcd, thread, debugDebug) },
   414  	}
   415  
   416  	// Prefer showing cards later in the order.
   417  	checkForNeverBeforeSeenCards := func(ctx context.Context, types []chat1.JourneycardType, breakOnShown bool) *chat1.JourneycardType {
   418  		for i := len(types) - 1; i >= 0; i-- {
   419  			cardType := types[i]
   420  			if jcd.hasShownOrDismissedOrLockout(convID, cardType) {
   421  				if breakOnShown {
   422  					break
   423  				} else {
   424  					continue
   425  				}
   426  			}
   427  			if cond, ok := cardConditions[cardType]; ok && cond(ctx) {
   428  				cc.Debug(ctx, "selected new card: %v", cardType)
   429  				return &cardType
   430  			}
   431  		}
   432  		return nil
   433  	}
   434  
   435  	var latestPage bool
   436  	if len(thread.Messages) > 0 && conv.MaxVisibleMsgID() > 0 {
   437  		end1 := thread.Messages[0].GetMessageID()
   438  		end2 := thread.Messages[len(thread.Messages)-1].GetMessageID()
   439  		leeway := chat1.MessageID(4) // Some fudge factor in case latest messages are not visible.
   440  		latestPage = (end1+leeway) >= conv.MaxVisibleMsgID() || (end2+leeway) >= conv.MaxVisibleMsgID()
   441  		if !latestPage {
   442  			cc.Debug(ctx, "non-latest page maxvis:%v end1:%v end2:%v", conv.MaxVisibleMsgID(), end1, end2)
   443  		}
   444  	}
   445  	// One might expect thread.Pagination.FirstPage() to be used instead of latestPage.
   446  	// But FirstPage seems to return false often when latestPage is true.
   447  
   448  	if latestPage {
   449  		// Prefer showing new "linear" cards. Do not show cards that are prior to one that has been shown.
   450  		if cardType := checkForNeverBeforeSeenCards(ctx, linearCardOrder, true); cardType != nil {
   451  			return makeCard(*cardType, 0, true)
   452  		}
   453  		// Show any new loose cards. It's fine to show A even if C has already been seen.
   454  		if cardType := checkForNeverBeforeSeenCards(ctx, looseCardOrder, false); cardType != nil {
   455  			return makeCard(*cardType, 0, true)
   456  		}
   457  	}
   458  
   459  	// TODO card type: MSG_ATTENTION (5 on design)
   460  	// Gist: "One of your messages is getting a lot of attention! <pointer to message>"
   461  	// Condition: The logged-in user's message gets a lot of reacjis
   462  	// Condition: That message is above the fold.
   463  
   464  	// No new cards selected. Pick the already-shown card with the most recent prev message ID.
   465  	debugDebug(ctx, "no new cards selected")
   466  	var mostRecentCardType chat1.JourneycardType
   467  	var mostRecentPrev chat1.MessageID
   468  	for cardType, savedPos := range jcd.Convs[convID.ConvIDStr()].Positions {
   469  		if savedPos == nil || jcd.hasDismissed(cardType) {
   470  			continue
   471  		}
   472  		// Break ties in PrevID using cardType's arbitrary enum value.
   473  		if savedPos.PrevID >= mostRecentPrev && (savedPos.PrevID != mostRecentPrev || cardType > mostRecentCardType) {
   474  			mostRecentCardType = cardType
   475  			mostRecentPrev = savedPos.PrevID
   476  		}
   477  	}
   478  	if mostRecentPrev != 0 {
   479  		switch mostRecentCardType {
   480  		case chat1.JourneycardType_CHANNEL_INACTIVE, chat1.JourneycardType_MSG_NO_ANSWER:
   481  			// Special case for these card types. These cards are pointing out a lack of activity
   482  			// in a conv. Subsequent activity in the conv should dismiss them.
   483  			if cc.messageSince(ctx, mostRecentPrev, conv, thread, debugDebug) {
   484  				debugDebug(ctx, "dismissing most recent saved card: %v", mostRecentCardType)
   485  				go cc.Dismiss(globals.BackgroundChatCtx(ctx, cc.G()), teamID, convID, mostRecentCardType)
   486  			} else {
   487  				debugDebug(ctx, "selected most recent saved card: %v", mostRecentCardType)
   488  				return makeCard(mostRecentCardType, 0, true)
   489  			}
   490  		default:
   491  			debugDebug(ctx, "selected most recent saved card: %v", mostRecentCardType)
   492  			return makeCard(mostRecentCardType, 0, true)
   493  		}
   494  	}
   495  
   496  	debugDebug(ctx, "no card at end of checks")
   497  	return nil, nil
   498  }
   499  
   500  // Card type: WELCOME (1 on design)
   501  // Condition: Only in #general channel
   502  // Condition: Less than 4 weeks have passed since the user joined the team (ish: see JoinedTime).
   503  func (cc *JourneyCardManagerSingleUser) cardWelcome(ctx context.Context, convID chat1.ConversationID, conv convForJourneycard, jcd journeycardData, debugDebug logFn) bool {
   504  	// TODO PICNIC-593 Welcome's interaction with existing system message
   505  	// Welcome cards show not show for all pre-existing teams when a client upgrades to first support journey cards. That would be a bad transition.
   506  	// The server gates whether welcome cards are allowed for a conv. After MarkAsRead-ing a conv, welcome cards are banned.
   507  	if !conv.IsGeneralChannel {
   508  		return false
   509  	}
   510  	debugDebug(ctx, "cardWelcome: welcomeEligible: %v", conv.WelcomeEligible)
   511  	return conv.IsGeneralChannel && conv.WelcomeEligible && cc.timeSinceJoinedLE(ctx, conv.TeamID, conv.ConvID, jcd, cardSinceJoinedCap)
   512  }
   513  
   514  // Card type: POPULAR_CHANNELS (2 on design)
   515  // Gist: "You are in #general. Other popular channels in this team: diplomacy, sportsball"
   516  // Condition: Only in #general channel
   517  // Condition: The team has at least 2 channels besides general that the user could join.
   518  // Condition: The user has not joined any other channels in the team.
   519  // Condition: User has sent a first message OR a few days have passed since they joined the channel.
   520  // Condition: Less than 4 weeks have passed since the user joined the team (ish: see JoinedTime).
   521  func (cc *JourneyCardManagerSingleUser) cardPopularChannels(ctx context.Context, conv convForJourneycard,
   522  	jcd journeycardData, debugDebug logFn) bool {
   523  	otherChannelsExist := conv.GetTeamType() == chat1.TeamType_COMPLEX
   524  	simpleQualified := conv.IsGeneralChannel && otherChannelsExist && (jcd.Convs[conv.ConvID.ConvIDStr()].SentMessage || cc.timeSinceJoinedInRange(ctx, conv.TeamID, conv.ConvID, jcd, time.Hour*24*2, cardSinceJoinedCap))
   525  	if !simpleQualified {
   526  		return false
   527  	}
   528  	// Find other channels that the user could join, or that they have joined.
   529  	// Don't get the actual channel names, since for NEVER_JOINED convs LocalMetadata,
   530  	// which has the name, is not generally available. The gui will fetch the names async.
   531  	topicType := chat1.TopicType_CHAT
   532  	joinableStatuses := []chat1.ConversationMemberStatus{ // keep in sync with cards/team-journey/container.tsx
   533  		chat1.ConversationMemberStatus_REMOVED,
   534  		chat1.ConversationMemberStatus_LEFT,
   535  		chat1.ConversationMemberStatus_RESET,
   536  		chat1.ConversationMemberStatus_NEVER_JOINED,
   537  	}
   538  	inbox, err := cc.G().InboxSource.ReadUnverified(ctx, cc.uid, types.InboxSourceDataSourceLocalOnly,
   539  		&chat1.GetInboxQuery{
   540  			TlfID:            &conv.TlfID,
   541  			TopicType:        &topicType,
   542  			MemberStatus:     append(append([]chat1.ConversationMemberStatus{}, joinableStatuses...), everJoinedStatuses...),
   543  			MembersTypes:     []chat1.ConversationMembersType{chat1.ConversationMembersType_TEAM},
   544  			SummarizeMaxMsgs: true,
   545  			SkipBgLoads:      true,
   546  			AllowUnseenQuery: true, // Make an effort, it's ok if convs are missed.
   547  		})
   548  	if err != nil {
   549  		debugDebug(ctx, "cardPopularChannels ReadUnverified error: %v", err)
   550  		return false
   551  	}
   552  	const nJoinableChannelsMin int = 2
   553  	var nJoinableChannels int
   554  	for _, convOther := range inbox.ConvsUnverified {
   555  		if !convOther.GetConvID().Eq(conv.ConvID) {
   556  			if convOther.Conv.ReaderInfo == nil {
   557  				debugDebug(ctx, "cardPopularChannels ReadUnverified missing ReaderInfo: %v", convOther.GetConvID())
   558  				continue
   559  			}
   560  			if memberStatusListContains(everJoinedStatuses, convOther.Conv.ReaderInfo.Status) {
   561  				debugDebug(ctx, "cardPopularChannels ReadUnverified found already-joined conv among %v: %v", len(inbox.ConvsUnverified), convOther.GetConvID())
   562  				return false
   563  			}
   564  			// Found joinable conv
   565  			nJoinableChannels++
   566  		}
   567  	}
   568  	debugDebug(ctx, "cardPopularChannels ReadUnverified found joinable convs %v / %v", nJoinableChannels, len(inbox.ConvsUnverified))
   569  	return nJoinableChannels >= nJoinableChannelsMin
   570  }
   571  
   572  // Card type: ADD_PEOPLE (3 on design)
   573  // Gist: "Do you know people interested in joining?"
   574  // Condition: Only in #general channel
   575  // Condition: User is an admin.
   576  // Condition: User has sent messages OR joined channels.
   577  // Condition: A few days on top of POPULAR_CHANNELS have passed since the user joined the channel. In order to space it out from POPULAR_CHANNELS.
   578  // Condition: Less than 4 weeks have passed since the user joined the team (ish: see JoinedTime).
   579  func (cc *JourneyCardManagerSingleUser) cardAddPeople(ctx context.Context, conv convForJourneycard, jcd journeycardData,
   580  	debugDebug logFn) bool {
   581  	if !conv.IsGeneralChannel || !conv.UntrustedTeamRole.IsAdminOrAbove() {
   582  		return false
   583  	}
   584  	if !cc.timeSinceJoinedInRange(ctx, conv.TeamID, conv.ConvID, jcd, time.Hour*24*4, cardSinceJoinedCap) {
   585  		return false
   586  	}
   587  	if jcd.Convs[conv.ConvID.ConvIDStr()].SentMessage {
   588  		return true
   589  	}
   590  	// Figure whether the user has ever joined other channels.
   591  	topicType := chat1.TopicType_CHAT
   592  	inbox, err := cc.G().InboxSource.ReadUnverified(ctx, cc.uid, types.InboxSourceDataSourceLocalOnly,
   593  		&chat1.GetInboxQuery{
   594  			TlfID:            &conv.TlfID,
   595  			TopicType:        &topicType,
   596  			MemberStatus:     everJoinedStatuses,
   597  			MembersTypes:     []chat1.ConversationMembersType{chat1.ConversationMembersType_TEAM},
   598  			SummarizeMaxMsgs: true,
   599  			SkipBgLoads:      true,
   600  			AllowUnseenQuery: true, // Make an effort, it's ok if convs are missed.
   601  		})
   602  	if err != nil {
   603  		debugDebug(ctx, "cardAddPeople ReadUnverified error: %v", err)
   604  		return false
   605  	}
   606  	debugDebug(ctx, "cardAddPeople ReadUnverified found %v convs", len(inbox.ConvsUnverified))
   607  	for _, convOther := range inbox.ConvsUnverified {
   608  		if !convOther.GetConvID().Eq(conv.ConvID) {
   609  			debugDebug(ctx, "cardAddPeople ReadUnverified found alternate conv: %v", convOther.GetConvID())
   610  			return true
   611  		}
   612  	}
   613  	return false
   614  }
   615  
   616  // Card type: CREATE_CHANNELS (4 on design)
   617  // Gist: "Go ahead and create #channels around topics you think are missing."
   618  // Condition: User is at least a writer.
   619  // Condition: A few weeks have passed.
   620  // Condition: User has sent a message.
   621  // Condition: There are <= 2 channels in the team.
   622  // Condition: Less than 4 weeks have passed since the user joined the team (ish: see JoinedTime).
   623  func (cc *JourneyCardManagerSingleUser) cardCreateChannels(ctx context.Context, conv convForJourneycard, jcd journeycardData, debugDebug logFn) bool {
   624  	if !conv.UntrustedTeamRole.IsWriterOrAbove() {
   625  		return false
   626  	}
   627  	if !jcd.Convs[conv.ConvID.ConvIDStr()].SentMessage {
   628  		return false
   629  	}
   630  	if !cc.timeSinceJoinedInRange(ctx, conv.TeamID, conv.ConvID, jcd, time.Hour*24*14, cardSinceJoinedCap) {
   631  		return false
   632  	}
   633  	if conv.GetTeamType() == chat1.TeamType_SIMPLE {
   634  		return true
   635  	}
   636  	// Figure out how many channels exist.
   637  	topicType := chat1.TopicType_CHAT
   638  	inbox, err := cc.G().InboxSource.ReadUnverified(ctx, cc.uid, types.InboxSourceDataSourceLocalOnly,
   639  		&chat1.GetInboxQuery{
   640  			TlfID:            &conv.TlfID,
   641  			TopicType:        &topicType,
   642  			MemberStatus:     allConvMemberStatuses,
   643  			MembersTypes:     []chat1.ConversationMembersType{chat1.ConversationMembersType_TEAM},
   644  			SummarizeMaxMsgs: true,
   645  			SkipBgLoads:      true,
   646  			AllowUnseenQuery: true, // Make an effort, it's ok if convs are missed.
   647  		})
   648  	if err != nil {
   649  		debugDebug(ctx, "cardCreateChannels ReadUnverified error: %v", err)
   650  		return false
   651  	}
   652  	debugDebug(ctx, "cardCreateChannels ReadUnverified found %v convs", len(inbox.ConvsUnverified))
   653  	return len(inbox.ConvsUnverified) <= 2
   654  }
   655  
   656  // Card type: MSG_NO_ANSWER (C)
   657  // Gist: "People haven't been talkative in a while. Perhaps post in another channel? <list of channels>"
   658  // Condition: In a channel besides general.
   659  // Condition: The last visible message is old, was sent by the logged-in user, and was a long text message, and has not been reacted to.
   660  func (cc *JourneyCardManagerSingleUser) cardMsgNoAnswer(ctx context.Context, conv convForJourneycard,
   661  	jcd journeycardData, thread *chat1.ThreadView, debugDebug logFn) bool {
   662  	if conv.IsGeneralChannel {
   663  		return false
   664  	}
   665  	// If the latest message is eligible then show the card.
   666  	var eligibleMsg chat1.MessageID  // maximum eligible msg
   667  	var preventerMsg chat1.MessageID // maximum preventer msg
   668  	save := func(msgID chat1.MessageID, eligible bool) {
   669  		if eligible {
   670  			if msgID > eligibleMsg {
   671  				eligibleMsg = msgID
   672  			}
   673  		} else {
   674  			if msgID > preventerMsg {
   675  				preventerMsg = msgID
   676  			}
   677  		}
   678  	}
   679  	for _, msg := range thread.Messages {
   680  		state, err := msg.State()
   681  		if err != nil {
   682  			continue
   683  		}
   684  		switch state {
   685  		case chat1.MessageUnboxedState_VALID:
   686  			eligible := func() bool {
   687  				if !msg.IsValidFull() {
   688  					return false
   689  				}
   690  				if !msg.Valid().ClientHeader.Sender.Eq(cc.uid) {
   691  					return false
   692  				}
   693  				switch msg.GetMessageType() {
   694  				case chat1.MessageType_TEXT:
   695  					const howLongIsLong = 40
   696  					const howOldIsOld = time.Hour * 24 * 3
   697  					isLong := (len(msg.Valid().MessageBody.Text().Body) >= howLongIsLong)
   698  					isOld := (cc.G().GetClock().Since(msg.Valid().ServerHeader.Ctime.Time().Add(-jcd.TimeOffset.ToDuration())) >= howOldIsOld)
   699  					hasNoReactions := len(msg.Valid().Reactions.Reactions) == 0
   700  					answer := isLong && isOld && hasNoReactions
   701  					return answer
   702  				default:
   703  					return false
   704  				}
   705  			}
   706  			if eligible() {
   707  				save(msg.GetMessageID(), true)
   708  			} else {
   709  				save(msg.GetMessageID(), false)
   710  			}
   711  		case chat1.MessageUnboxedState_ERROR:
   712  			save(msg.Error().MessageID, false)
   713  		case chat1.MessageUnboxedState_OUTBOX:
   714  			// If there's something in the outbox, don't show this card.
   715  			return false
   716  		case chat1.MessageUnboxedState_PLACEHOLDER:
   717  			save(msg.Placeholder().MessageID, false)
   718  		case chat1.MessageUnboxedState_JOURNEYCARD:
   719  			save(msg.Journeycard().PrevID, false)
   720  		default:
   721  			debugDebug(ctx, "unrecognized message state: %v", state)
   722  			continue
   723  		}
   724  	}
   725  	result := eligibleMsg != 0 && eligibleMsg >= preventerMsg
   726  	if result {
   727  		debugDebug(ctx, "cardMsgNoAnswer result:%v eligible:%v preventer:%v n:%v", result, eligibleMsg, preventerMsg, len(thread.Messages))
   728  	}
   729  	return result
   730  }
   731  
   732  // Card type: CHANNEL_INACTIVE (B on design)
   733  // Gist: "Zzz... This channel hasn't been very active... Revive it?"
   734  // Condition: User can write in the channel.
   735  // Condition: The last visible message is old.
   736  // Condition: A card besides WELCOME has been shown in the team.
   737  func (cc *JourneyCardManagerSingleUser) cardChannelInactive(ctx context.Context,
   738  	conv convForJourneycard, jcd journeycardData, thread *chat1.ThreadView,
   739  	debugDebug logFn) bool {
   740  	if conv.CannotWrite || !jcd.ShownCardBesidesWelcome {
   741  		return false
   742  	}
   743  	// If the latest message is eligible then show the card.
   744  	var eligibleMsg chat1.MessageID  // maximum eligible msg
   745  	var preventerMsg chat1.MessageID // maximum preventer msg
   746  	save := func(msgID chat1.MessageID, eligible bool) {
   747  		if eligible {
   748  			if msgID > eligibleMsg {
   749  				eligibleMsg = msgID
   750  			}
   751  		} else {
   752  			if msgID > preventerMsg {
   753  				preventerMsg = msgID
   754  			}
   755  		}
   756  	}
   757  	for _, msg := range thread.Messages {
   758  		state, err := msg.State()
   759  		if err != nil {
   760  			continue
   761  		}
   762  		switch state {
   763  		case chat1.MessageUnboxedState_VALID:
   764  			eligible := func() bool {
   765  				if !msg.IsValidFull() {
   766  					return false
   767  				}
   768  				const howOldIsOld = time.Hour * 24 * 8
   769  				isOld := (cc.G().GetClock().Since(msg.Valid().ServerHeader.Ctime.Time().Add(-jcd.TimeOffset.ToDuration())) >= howOldIsOld)
   770  				return isOld
   771  			}
   772  			if eligible() {
   773  				save(msg.GetMessageID(), true)
   774  			} else {
   775  				save(msg.GetMessageID(), false)
   776  			}
   777  		case chat1.MessageUnboxedState_ERROR:
   778  			save(msg.Error().MessageID, false)
   779  		case chat1.MessageUnboxedState_OUTBOX:
   780  			// If there's something in the outbox, don't show this card.
   781  			return false
   782  		case chat1.MessageUnboxedState_PLACEHOLDER:
   783  			save(msg.Placeholder().MessageID, false)
   784  		case chat1.MessageUnboxedState_JOURNEYCARD:
   785  			save(msg.Journeycard().PrevID, false)
   786  		default:
   787  			cc.Debug(ctx, "unrecognized message state: %v", state)
   788  			continue
   789  		}
   790  	}
   791  	result := eligibleMsg != 0 && eligibleMsg >= preventerMsg
   792  	if result {
   793  		debugDebug(ctx, "cardChannelInactive result:%v eligible:%v preventer:%v n:%v", result, eligibleMsg, preventerMsg, len(thread.Messages))
   794  	}
   795  	return result
   796  }
   797  
   798  func (cc *JourneyCardManagerSingleUser) timeSinceJoinedInRange(ctx context.Context, teamID keybase1.TeamID, convID chat1.ConversationID, jcd journeycardData, minDuration time.Duration, maxDuration time.Duration) bool {
   799  	joinedTime := jcd.Convs[convID.ConvIDStr()].JoinedTime
   800  	if joinedTime == nil {
   801  		go cc.saveJoinedTime(globals.BackgroundChatCtx(ctx, cc.G()), teamID, convID, cc.G().GetClock().Now())
   802  		return false
   803  	}
   804  	since := cc.G().GetClock().Since(joinedTime.Time().Add(-jcd.TimeOffset.ToDuration()))
   805  	return since >= minDuration && since <= maxDuration
   806  }
   807  
   808  // JoinedTime <= duration
   809  func (cc *JourneyCardManagerSingleUser) timeSinceJoinedLE(ctx context.Context, teamID keybase1.TeamID, convID chat1.ConversationID, jcd journeycardData, duration time.Duration) bool {
   810  	joinedTime := jcd.Convs[convID.ConvIDStr()].JoinedTime
   811  	if joinedTime == nil {
   812  		go cc.saveJoinedTime(globals.BackgroundChatCtx(ctx, cc.G()), teamID, convID, cc.G().GetClock().Now())
   813  		return true
   814  	}
   815  	return cc.G().GetClock().Since(joinedTime.Time().Add(-jcd.TimeOffset.ToDuration())) <= duration
   816  }
   817  
   818  func (cc *JourneyCardManagerSingleUser) messageSince(ctx context.Context, msgID chat1.MessageID,
   819  	conv convForJourneycard, thread *chat1.ThreadView, debugDebug logFn) bool {
   820  	for _, msg := range thread.Messages {
   821  		state, err := msg.State()
   822  		if err != nil {
   823  			continue
   824  		}
   825  		switch state {
   826  		case chat1.MessageUnboxedState_VALID, chat1.MessageUnboxedState_ERROR, chat1.MessageUnboxedState_PLACEHOLDER:
   827  			if msg.GetMessageID() > msgID {
   828  				return true
   829  			}
   830  		case chat1.MessageUnboxedState_OUTBOX:
   831  			return true
   832  		case chat1.MessageUnboxedState_JOURNEYCARD:
   833  		default:
   834  			debugDebug(ctx, "unrecognized message state: %v", state)
   835  			continue
   836  		}
   837  	}
   838  	return false
   839  }
   840  
   841  // The user has sent a message.
   842  func (cc *JourneyCardManagerSingleUser) SentMessage(ctx context.Context, teamID keybase1.TeamID, convID chat1.ConversationID) {
   843  	err := libkb.AcquireWithContextAndTimeout(ctx, &cc.storageLock, 10*time.Second)
   844  	if err != nil {
   845  		cc.Debug(ctx, "SentMessage storageLock error: %v", err)
   846  		return
   847  	}
   848  	defer cc.storageLock.Unlock()
   849  	if teamID.IsNil() || convID.IsNil() {
   850  		return
   851  	}
   852  	jcd, err := cc.getTeamDataWithLock(ctx, teamID)
   853  	if err != nil {
   854  		cc.Debug(ctx, "storage get error: %v", err)
   855  		return
   856  	}
   857  	if jcd.Convs[convID.ConvIDStr()].SentMessage {
   858  		return
   859  	}
   860  	jcd = jcd.MutateConv(convID, func(conv journeycardConvData) journeycardConvData {
   861  		conv.SentMessage = true
   862  		return conv
   863  	})
   864  	cc.lru.Add(teamID.String(), jcd)
   865  	err = cc.encryptedDB.Put(ctx, cc.dbKey(teamID), jcd)
   866  	if err != nil {
   867  		cc.Debug(ctx, "storage put error: %v", err)
   868  	}
   869  	cc.saveJoinedTimeWithLock(globals.BackgroundChatCtx(ctx, cc.G()), teamID, convID, cc.G().GetClock().Now())
   870  }
   871  
   872  func (cc *JourneyCardManagerSingleUser) Dismiss(ctx context.Context, teamID keybase1.TeamID, convID chat1.ConversationID, cardType chat1.JourneycardType) {
   873  	var err error
   874  	defer cc.G().CTrace(ctx, fmt.Sprintf("JourneyCardManagerSingleUser.Dismiss(cardType:%v, teamID:%v, convID:%v)",
   875  		cardType, teamID, convID.DbShortFormString()), &err)()
   876  	err = libkb.AcquireWithContextAndTimeout(ctx, &cc.storageLock, 10*time.Second)
   877  	if err != nil {
   878  		cc.Debug(ctx, "Dismiss storageLock error: %v", err)
   879  		return
   880  	}
   881  	defer cc.storageLock.Unlock()
   882  	if convID.IsNil() {
   883  		return
   884  	}
   885  	jcd, err := cc.getTeamDataWithLock(ctx, teamID)
   886  	if err != nil {
   887  		cc.Debug(ctx, "storage get error: %v", err)
   888  		return
   889  	}
   890  	if jcd.Dismissals[cardType] {
   891  		return
   892  	}
   893  	jcd = jcd.PrepareToMutateDismissals() // clone Dismissals to avoid modifying shared conv.
   894  	jcd.Dismissals[cardType] = true
   895  	cc.lru.Add(teamID.String(), jcd)
   896  	err = cc.encryptedDB.Put(ctx, cc.dbKey(teamID), jcd)
   897  	if err != nil {
   898  		cc.Debug(ctx, "storage put error: %v", err)
   899  	}
   900  }
   901  
   902  func (cc *JourneyCardManagerSingleUser) dbKey(teamID keybase1.TeamID) libkb.DbKey {
   903  	return libkb.DbKey{
   904  		Typ: libkb.DBChatJourney,
   905  		// Key: fmt.Sprintf("jc|uid:%s|convID:%s", cc.uid, convID), // used with DiskVersion 1
   906  		Key: fmt.Sprintf("jc|uid:%s|teamID:%s", cc.uid, teamID),
   907  	}
   908  }
   909  
   910  // Get info about a team and its conversations.
   911  // Note the return value may share internal structure with other threads. Do not deeply modify.
   912  func (cc *JourneyCardManagerSingleUser) getTeamData(ctx context.Context, teamID keybase1.TeamID) (res journeycardData, err error) {
   913  	err = libkb.AcquireWithContextAndTimeout(ctx, &cc.storageLock, 10*time.Second)
   914  	if err != nil {
   915  		return res, fmt.Errorf("getTeamData storageLock error: %v", err)
   916  	}
   917  	defer cc.storageLock.Unlock()
   918  	return cc.getTeamDataWithLock(ctx, teamID)
   919  }
   920  
   921  func (cc *JourneyCardManagerSingleUser) getTeamDataWithLock(ctx context.Context, teamID keybase1.TeamID) (res journeycardData, err error) {
   922  	if teamID.IsNil() {
   923  		return res, fmt.Errorf("missing teamID")
   924  	}
   925  	untyped, ok := cc.lru.Get(teamID.String())
   926  	if ok {
   927  		res, ok = untyped.(journeycardData)
   928  		if !ok {
   929  			return res, fmt.Errorf("JourneyCardManager.getConvData got unexpected type: %T", untyped)
   930  		}
   931  		return res, nil
   932  	}
   933  	// Fetch from persistent storage.
   934  	found, err := cc.encryptedDB.Get(ctx, cc.dbKey(teamID), &res)
   935  	if err != nil {
   936  		// This could be something like a "msgpack decode error" due to a severe change to the storage schema.
   937  		// If care is taken when changing storage schema, this shouldn't happen. But just in case,
   938  		// better to start over than to remain stuck.
   939  		cc.Debug(ctx, "db error: %v", err)
   940  		found = false
   941  	}
   942  	if found {
   943  		switch res.DiskVersion {
   944  		case 1:
   945  			// Version 1 is obsolete. Ignore it.
   946  			res = newJourneycardData()
   947  		case journeycardDiskVersion:
   948  			// good
   949  		default:
   950  			cc.Debug(ctx, "converting jcd version %v -> %v", res.DiskVersion, journeycardDiskVersion)
   951  			// Accept any subset of the data that was deserialized.
   952  		}
   953  	} else {
   954  		res = newJourneycardData()
   955  	}
   956  	cc.lru.Add(teamID.String(), res)
   957  	return res, nil
   958  }
   959  
   960  func (cc *JourneyCardManagerSingleUser) hasTeam(ctx context.Context, teamID keybase1.TeamID) (found bool, nConvs int, err error) {
   961  	err = libkb.AcquireWithContextAndTimeout(ctx, &cc.storageLock, 10*time.Second)
   962  	if err != nil {
   963  		return false, 0, fmt.Errorf("getTeamData storageLock error: %v", err)
   964  	}
   965  	defer cc.storageLock.Unlock()
   966  	var jcd journeycardData
   967  	found, err = cc.encryptedDB.Get(ctx, cc.dbKey(teamID), &jcd)
   968  	if err != nil || !found {
   969  		return found, 0, err
   970  	}
   971  	return found, len(jcd.Convs), nil
   972  }
   973  
   974  func (cc *JourneyCardManagerSingleUser) savePosition(ctx context.Context, teamID keybase1.TeamID, convID chat1.ConversationID, cardType chat1.JourneycardType, pos journeyCardPosition) {
   975  	err := libkb.AcquireWithContextAndTimeout(ctx, &cc.storageLock, 10*time.Second)
   976  	if err != nil {
   977  		cc.Debug(ctx, "savePosition storageLock error: %v", err)
   978  		return
   979  	}
   980  	defer cc.storageLock.Unlock()
   981  	if teamID.IsNil() || convID.IsNil() {
   982  		return
   983  	}
   984  	jcd, err := cc.getTeamDataWithLock(ctx, teamID)
   985  	if err != nil {
   986  		cc.Debug(ctx, "storage get error: %v", err)
   987  		return
   988  	}
   989  	if conv, ok := jcd.Convs[convID.ConvIDStr()]; ok {
   990  		if existing, ok := conv.Positions[cardType]; ok && existing != nil && *existing == pos {
   991  			if !journeycardTypeOncePerTeam[cardType] || jcd.Lockin[cardType].Eq(convID) {
   992  				// no change
   993  				return
   994  			}
   995  		}
   996  	}
   997  	jcd = jcd.MutateConv(convID, func(conv journeycardConvData) journeycardConvData {
   998  		conv = conv.PrepareToMutatePositions() // clone Positions to avoid modifying shared conv.
   999  		conv.Positions[cardType] = &pos
  1000  		return conv
  1001  	})
  1002  	if journeycardTypeOncePerTeam[cardType] {
  1003  		jcd = jcd.SetLockin(cardType, convID)
  1004  	}
  1005  	if cardType != chat1.JourneycardType_WELCOME {
  1006  		jcd.ShownCardBesidesWelcome = true
  1007  	}
  1008  	cc.lru.Add(teamID.String(), jcd)
  1009  	err = cc.encryptedDB.Put(ctx, cc.dbKey(teamID), jcd)
  1010  	if err != nil {
  1011  		cc.Debug(ctx, "storage put error: %v", err)
  1012  	}
  1013  }
  1014  
  1015  // Save the time the user joined. Discards value if one is already saved.
  1016  func (cc *JourneyCardManagerSingleUser) saveJoinedTime(ctx context.Context, teamID keybase1.TeamID, convID chat1.ConversationID, t time.Time) {
  1017  	err := libkb.AcquireWithContextAndTimeout(ctx, &cc.storageLock, 10*time.Second)
  1018  	if err != nil {
  1019  		cc.Debug(ctx, "saveJoinedTime storageLock error: %v", err)
  1020  		return
  1021  	}
  1022  	defer cc.storageLock.Unlock()
  1023  	cc.saveJoinedTimeWithLock(ctx, teamID, convID, t)
  1024  }
  1025  
  1026  func (cc *JourneyCardManagerSingleUser) saveJoinedTimeWithLock(ctx context.Context, teamID keybase1.TeamID, convID chat1.ConversationID, t time.Time) {
  1027  	cc.saveJoinedTimeWithLockInner(ctx, teamID, convID, t, false)
  1028  }
  1029  
  1030  func (cc *JourneyCardManagerSingleUser) saveJoinedTimeWithLockInner(ctx context.Context, teamID keybase1.TeamID, convID chat1.ConversationID, t time.Time, acceptUpdate bool) {
  1031  	if teamID.IsNil() || convID.IsNil() {
  1032  		return
  1033  	}
  1034  	jcd, err := cc.getTeamDataWithLock(ctx, teamID)
  1035  	if err != nil {
  1036  		cc.Debug(ctx, "storage get error: %v", err)
  1037  		return
  1038  	}
  1039  	if jcd.Convs[convID.ConvIDStr()].JoinedTime != nil && !acceptUpdate {
  1040  		return
  1041  	}
  1042  	t2 := gregor1.ToTime(t)
  1043  	jcd = jcd.MutateConv(convID, func(conv journeycardConvData) journeycardConvData {
  1044  		conv.JoinedTime = &t2
  1045  		return conv
  1046  	})
  1047  	cc.lru.Add(teamID.String(), jcd)
  1048  	err = cc.encryptedDB.Put(ctx, cc.dbKey(teamID), jcd)
  1049  	if err != nil {
  1050  		cc.Debug(ctx, "storage put error: %v", err)
  1051  	}
  1052  }
  1053  
  1054  func (cc *JourneyCardManagerSingleUser) isOpenTeam(ctx context.Context, conv convForJourneycard) (open bool, err error) {
  1055  	teamID, err := keybase1.TeamIDFromString(conv.TlfID.String())
  1056  	if err != nil {
  1057  		return false, err
  1058  	}
  1059  	return cc.G().GetTeamLoader().IsOpenCached(ctx, teamID)
  1060  }
  1061  
  1062  // TimeTravel simulates moving all known conversations forward in time.
  1063  // For use simulating a user experience without the need to wait hours for cards to appear.
  1064  // Returns the number of known teams and convs.
  1065  func (cc *JourneyCardManagerSingleUser) TimeTravel(ctx context.Context, duration time.Duration) (nTeams, nConvs int, err error) {
  1066  	err = libkb.AcquireWithContextAndTimeout(ctx, &cc.storageLock, 10*time.Second)
  1067  	if err != nil {
  1068  		return 0, 0, err
  1069  	}
  1070  	defer cc.storageLock.Unlock()
  1071  	teamIDs, err := cc.getKnownTeamsForDebuggingWithLock(ctx)
  1072  	if err != nil {
  1073  		return 0, 0, err
  1074  	}
  1075  	for _, teamID := range teamIDs {
  1076  		jcd, err := cc.getTeamDataWithLock(ctx, teamID)
  1077  		if err != nil {
  1078  			return len(teamIDs), 0, fmt.Errorf("teamID:%v err:%v", teamID, err)
  1079  		}
  1080  		jcd.TimeOffset = gregor1.ToDurationMsec(jcd.TimeOffset.ToDuration() + duration)
  1081  		cc.Debug(ctx, "time travel teamID:%v", teamID, jcd.TimeOffset)
  1082  		nConvs += len(jcd.Convs)
  1083  		cc.lru.Add(teamID.String(), jcd)
  1084  		err = cc.encryptedDB.Put(ctx, cc.dbKey(teamID), jcd)
  1085  		if err != nil {
  1086  			cc.Debug(ctx, "storage put error: %v", err)
  1087  			return len(teamIDs), 0, err
  1088  		}
  1089  	}
  1090  	return nConvs, len(teamIDs), nil
  1091  }
  1092  
  1093  // ResetAllConvs deletes storage for all conversations.
  1094  // For use simulating a fresh user experience without the need to switch accounts.
  1095  func (cc *JourneyCardManagerSingleUser) ResetAllConvs(ctx context.Context) (err error) {
  1096  	err = libkb.AcquireWithContextAndTimeout(ctx, &cc.storageLock, 10*time.Second)
  1097  	if err != nil {
  1098  		return err
  1099  	}
  1100  	defer cc.storageLock.Unlock()
  1101  	teamIDs, err := cc.getKnownTeamsForDebuggingWithLock(ctx)
  1102  	if err != nil {
  1103  		return err
  1104  	}
  1105  	cc.lru.Purge()
  1106  	for _, teamID := range teamIDs {
  1107  		err = cc.encryptedDB.Delete(ctx, cc.dbKey(teamID))
  1108  		if err != nil {
  1109  			return fmt.Errorf("teamID:%v err:%v", teamID, err)
  1110  		}
  1111  	}
  1112  	return nil
  1113  }
  1114  
  1115  func (cc *JourneyCardManagerSingleUser) DebugState(ctx context.Context, teamID keybase1.TeamID) (summary string, err error) {
  1116  	jcd, err := cc.getTeamData(ctx, teamID)
  1117  	if err != nil {
  1118  		return "", err
  1119  	}
  1120  	convs := jcd.Convs
  1121  	jcd.Convs = nil // Blank out convs for the first spew. They will be shown separately.
  1122  	summary = spew.Sdump(jcd)
  1123  	if jcd.TimeOffset != 0 {
  1124  		summary += fmt.Sprintf("\nTime travel offset: %v", jcd.TimeOffset.ToDuration())
  1125  	}
  1126  	for convIDStr, conv := range convs {
  1127  		summary += fmt.Sprintf("\n%v:\n%v", convIDStr, spew.Sdump(conv))
  1128  		if conv.JoinedTime != nil {
  1129  			since := cc.G().GetClock().Since(conv.JoinedTime.Time().Add(jcd.TimeOffset.ToDuration()))
  1130  			summary += fmt.Sprintf("Since joined: %v (%.1f days)", since, float64(since)/float64(time.Hour*24))
  1131  		}
  1132  	}
  1133  	return summary, nil
  1134  }
  1135  
  1136  func (cc *JourneyCardManagerSingleUser) getKnownTeamsForDebuggingWithLock(ctx context.Context) (teams []keybase1.TeamID, err error) {
  1137  	innerKeyPrefix := fmt.Sprintf("jc|uid:%s|teamID:", cc.uid)
  1138  	prefix := libkb.DbKey{
  1139  		Typ: libkb.DBChatJourney,
  1140  		Key: innerKeyPrefix,
  1141  	}.ToBytes()
  1142  	leveldb, ok := cc.G().LocalChatDb.GetEngine().(*libkb.LevelDb)
  1143  	if !ok {
  1144  		return nil, fmt.Errorf("could not get leveldb")
  1145  	}
  1146  	dbKeys, err := leveldb.KeysWithPrefixes(prefix)
  1147  	if err != nil {
  1148  		return nil, err
  1149  	}
  1150  	for dbKey := range dbKeys {
  1151  		if dbKey.Typ == libkb.DBChatJourney && strings.HasPrefix(dbKey.Key, innerKeyPrefix) {
  1152  			teamID, err := keybase1.TeamIDFromString(dbKey.Key[len(innerKeyPrefix):])
  1153  			if err != nil {
  1154  				return nil, err
  1155  			}
  1156  			teams = append(teams, teamID)
  1157  		}
  1158  	}
  1159  	return teams, nil
  1160  }
  1161  
  1162  type journeyCardPosition struct {
  1163  	PrevID chat1.MessageID `codec:"p,omitempty" json:"p,omitempty"`
  1164  }
  1165  
  1166  const journeycardDiskVersion int = 2
  1167  
  1168  // Storage for a single team's journey cards.
  1169  // Bump journeycardDiskVersion when making incompatible changes.
  1170  type journeycardData struct {
  1171  	DiskVersion int                                     `codec:"v,omitempty" json:"v,omitempty"`
  1172  	Convs       map[chat1.ConvIDStr]journeycardConvData `codec:"cv,omitempty" json:"cv,omitempty"`
  1173  	Dismissals  map[chat1.JourneycardType]bool          `codec:"ds,omitempty" json:"ds,omitempty"`
  1174  	// Some card types can only appear once. This map locks a type into a particular conv.
  1175  	Lockin                  map[chat1.JourneycardType]chat1.ConversationID `codec:"l,omitempty" json:"l,omitempty"`
  1176  	ShownCardBesidesWelcome bool                                           `codec:"sbw,omitempty" json:"sbw,omitempty"`
  1177  	// When this data was first saved. For debugging unexpected data loss.
  1178  	Ctime      gregor1.Time         `codec:"c,omitempty" json:"c,omitempty"`
  1179  	TimeOffset gregor1.DurationMsec `codec:"to,omitempty" json:"to,omitempty"` // Time travel for testing/debugging
  1180  }
  1181  
  1182  type journeycardConvData struct {
  1183  	// codec `d` has been used in the past for Dismissals
  1184  	Positions map[chat1.JourneycardType]*journeyCardPosition `codec:"p,omitempty" json:"p,omitempty"`
  1185  	// Whether the user has sent a message in this channel.
  1186  	SentMessage bool `codec:"sm,omitempty" json:"sm,omitempty"`
  1187  	// When the user joined the channel (that's the idea, really it's some time when they saw the conv)
  1188  	JoinedTime *gregor1.Time `codec:"jt,omitempty" json:"jt,omitempty"`
  1189  }
  1190  
  1191  func newJourneycardData() journeycardData {
  1192  	return journeycardData{
  1193  		DiskVersion: journeycardDiskVersion,
  1194  		Convs:       make(map[chat1.ConvIDStr]journeycardConvData),
  1195  		Dismissals:  make(map[chat1.JourneycardType]bool),
  1196  		Lockin:      make(map[chat1.JourneycardType]chat1.ConversationID),
  1197  		Ctime:       gregor1.ToTime(time.Now()),
  1198  	}
  1199  }
  1200  
  1201  func newJourneycardConvData() journeycardConvData {
  1202  	return journeycardConvData{
  1203  		Positions: make(map[chat1.JourneycardType]*journeyCardPosition),
  1204  	}
  1205  }
  1206  
  1207  // Return a new instance where the conv entry has been mutated.
  1208  // Without modifying the receiver itself.
  1209  // The caller should take that `apply` does not deeply mutate its argument.
  1210  // If the conversation did not exist, a new entry is created.
  1211  func (j *journeycardData) MutateConv(convID chat1.ConversationID, apply func(journeycardConvData) journeycardConvData) journeycardData {
  1212  	selectedConvIDStr := convID.ConvIDStr()
  1213  	updatedConvs := make(map[chat1.ConvIDStr]journeycardConvData)
  1214  	for convIDStr, conv := range j.Convs {
  1215  		if convIDStr == selectedConvIDStr {
  1216  			updatedConvs[convIDStr] = apply(conv)
  1217  		} else {
  1218  			updatedConvs[convIDStr] = conv
  1219  		}
  1220  	}
  1221  	if _, found := updatedConvs[selectedConvIDStr]; !found {
  1222  		updatedConvs[selectedConvIDStr] = apply(newJourneycardConvData())
  1223  	}
  1224  	res := *j // Copy so that Convs can be assigned without aliasing.
  1225  	res.Convs = updatedConvs
  1226  	return res
  1227  }
  1228  
  1229  // Return a new instance where Lockin has been modified.
  1230  // Without modifying the receiver itself.
  1231  func (j *journeycardData) SetLockin(cardType chat1.JourneycardType, convID chat1.ConversationID) (res journeycardData) {
  1232  	res = *j
  1233  	res.Lockin = make(map[chat1.JourneycardType]chat1.ConversationID)
  1234  	for k, v := range j.Lockin {
  1235  		res.Lockin[k] = v
  1236  	}
  1237  	res.Lockin[cardType] = convID
  1238  	return res
  1239  }
  1240  
  1241  // Whether this card type has one of:
  1242  // - already been shown (conv)
  1243  // - been dismissed (team wide)
  1244  // - lockin to a different conv (team wide)
  1245  func (j *journeycardData) hasShownOrDismissedOrLockout(convID chat1.ConversationID, cardType chat1.JourneycardType) bool {
  1246  	if j.Dismissals[cardType] {
  1247  		return true
  1248  	}
  1249  	if lockinConvID, found := j.Lockin[cardType]; found {
  1250  		if !lockinConvID.Eq(convID) {
  1251  			return true
  1252  		}
  1253  	}
  1254  	if c, found := j.Convs[convID.ConvIDStr()]; found {
  1255  		return c.Positions[cardType] != nil
  1256  	}
  1257  	return false
  1258  }
  1259  
  1260  // Whether this card type has been dismissed.
  1261  func (j *journeycardData) hasDismissed(cardType chat1.JourneycardType) bool {
  1262  	return j.Dismissals[cardType]
  1263  }
  1264  
  1265  func (j *journeycardData) PrepareToMutateDismissals() (res journeycardData) {
  1266  	res = *j
  1267  	res.Dismissals = make(map[chat1.JourneycardType]bool)
  1268  	for k, v := range j.Dismissals {
  1269  		res.Dismissals[k] = v
  1270  	}
  1271  	return res
  1272  }
  1273  
  1274  func (j *journeycardConvData) PrepareToMutatePositions() (res journeycardConvData) {
  1275  	res = *j
  1276  	res.Positions = make(map[chat1.JourneycardType]*journeyCardPosition)
  1277  	for k, v := range j.Positions {
  1278  		res.Positions[k] = v
  1279  	}
  1280  	return res
  1281  }
  1282  
  1283  type convForJourneycardInner interface {
  1284  	GetMembersType() chat1.ConversationMembersType
  1285  	GetTopicType() chat1.TopicType
  1286  	GetTopicName() string
  1287  	GetTeamType() chat1.TeamType
  1288  	MaxVisibleMsgID() chat1.MessageID
  1289  }
  1290  
  1291  type convForJourneycard struct {
  1292  	convForJourneycardInner
  1293  	ConvID            chat1.ConversationID
  1294  	IsGeneralChannel  bool
  1295  	UntrustedTeamRole keybase1.TeamRole
  1296  	TlfID             chat1.TLFID
  1297  	TeamID            keybase1.TeamID
  1298  	WelcomeEligible   bool
  1299  	CannotWrite       bool
  1300  }
  1301  
  1302  var journeycardTypeOncePerTeam = map[chat1.JourneycardType]bool{
  1303  	chat1.JourneycardType_WELCOME:          true,
  1304  	chat1.JourneycardType_POPULAR_CHANNELS: true,
  1305  	chat1.JourneycardType_ADD_PEOPLE:       true,
  1306  	chat1.JourneycardType_CREATE_CHANNELS:  true,
  1307  }
  1308  
  1309  var journeycardShouldNotRunOnReason = map[chat1.GetThreadReason]bool{
  1310  	chat1.GetThreadReason_BACKGROUNDCONVLOAD: true,
  1311  	chat1.GetThreadReason_FIXRETRY:           true,
  1312  	chat1.GetThreadReason_PREPARE:            true,
  1313  	chat1.GetThreadReason_SEARCHER:           true,
  1314  	chat1.GetThreadReason_INDEXED_SEARCH:     true,
  1315  	chat1.GetThreadReason_KBFSFILEACTIVITY:   true,
  1316  	chat1.GetThreadReason_COINFLIP:           true,
  1317  	chat1.GetThreadReason_BOTCOMMANDS:        true,
  1318  }
  1319  
  1320  // The user has joined the conversations at some point.
  1321  var everJoinedStatuses = []chat1.ConversationMemberStatus{
  1322  	chat1.ConversationMemberStatus_ACTIVE,
  1323  	chat1.ConversationMemberStatus_REMOVED,
  1324  	chat1.ConversationMemberStatus_LEFT,
  1325  	chat1.ConversationMemberStatus_PREVIEW,
  1326  }
  1327  
  1328  var allConvMemberStatuses []chat1.ConversationMemberStatus
  1329  
  1330  func init() {
  1331  	var allConvMemberStatuses []chat1.ConversationMemberStatus
  1332  	for s := range chat1.ConversationMemberStatusRevMap {
  1333  		allConvMemberStatuses = append(allConvMemberStatuses, s)
  1334  	}
  1335  	sort.Slice(allConvMemberStatuses, func(i, j int) bool { return allConvMemberStatuses[i] < allConvMemberStatuses[j] })
  1336  }
  1337  
  1338  func memberStatusListContains(a []chat1.ConversationMemberStatus, v chat1.ConversationMemberStatus) bool {
  1339  	for _, el := range a {
  1340  		if el == v {
  1341  			return true
  1342  		}
  1343  	}
  1344  	return false
  1345  }