github.com/keybase/client/go@v0.0.0-20241007131713-f10651d043c8/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 }