github.com/keybase/client/go@v0.0.0-20240309051027-028f7c731f8b/chatrender/chat_cli_rendering.go (about) 1 package chatrender 2 3 import ( 4 "context" 5 "fmt" 6 "io" 7 "math" 8 "sort" 9 "strconv" 10 "strings" 11 "time" 12 13 "github.com/keybase/client/go/flexibletable" 14 "github.com/keybase/client/go/libkb" 15 "github.com/keybase/client/go/protocol/chat1" 16 "github.com/keybase/client/go/protocol/gregor1" 17 "github.com/keybase/client/go/protocol/keybase1" 18 "github.com/keybase/client/go/protocol/stellar1" 19 "github.com/kyokomi/emoji" 20 ) 21 22 const publicConvNamePrefix = "(public) " 23 24 type ConversationInfoListView []chat1.ConversationLocal 25 26 func (v ConversationInfoListView) Show(g *libkb.GlobalContext) error { 27 ui := g.UI.GetTerminalUI() 28 w, _ := ui.TerminalSize() 29 return v.RenderToWriter(g, ui.OutputWriter(), w) 30 } 31 32 func (v ConversationInfoListView) RenderToWriter(g *libkb.GlobalContext, writer io.Writer, width int) error { 33 if len(v) == 0 { 34 return nil 35 } 36 37 table := &flexibletable.Table{} 38 for i, conv := range v { 39 var tlfName string 40 if conv.Error != nil { 41 tlfName = fmt.Sprintf("(unverified) %v", 42 formatUnverifiedConvName(conv.Error.UnverifiedTLFName, conv.Info.Visibility, g.Env.GetUsername().String())) 43 } else { 44 tlfName = conv.Info.TlfName 45 } 46 vis := "private" 47 if conv.Info.Visibility == keybase1.TLFVisibility_PUBLIC { 48 vis = "public" 49 } 50 var reset string 51 if conv.Info.FinalizeInfo != nil { 52 reset = conv.Info.FinalizeInfo.BeforeSummary() 53 } 54 err := table.Insert(flexibletable.Row{ 55 flexibletable.Cell{ 56 Frame: [2]string{"[", "]"}, 57 Alignment: flexibletable.Right, 58 Content: flexibletable.SingleCell{Item: strconv.Itoa(i + 1)}, 59 }, 60 flexibletable.Cell{ 61 Alignment: flexibletable.Left, 62 Content: flexibletable.SingleCell{Item: vis}, 63 }, 64 flexibletable.Cell{ 65 Alignment: flexibletable.Left, 66 Content: flexibletable.SingleCell{Item: tlfName}, 67 }, 68 flexibletable.Cell{ 69 Alignment: flexibletable.Left, 70 Content: flexibletable.SingleCell{Item: reset}, 71 }, 72 }) 73 if err != nil { 74 return err 75 } 76 } 77 if err := table.Render(writer, " ", width, []flexibletable.ColumnConstraint{ 78 15, // visualIndex 79 8, // vis 80 flexibletable.ExpandableWrappable, // participants 81 flexibletable.ExpandableWrappable, // reset 82 }); err != nil { 83 return fmt.Errorf("rendering conversation info list view error: %v\n", err) 84 } 85 86 return nil 87 } 88 89 type ConversationListView []chat1.ConversationLocal 90 91 func convNameTeam(g *libkb.GlobalContext, conv chat1.ConversationLocal) string { 92 return fmt.Sprintf("%s [#%s]", conv.Info.TlfName, conv.Info.TopicName) 93 } 94 95 func convNameKBFS(g *libkb.GlobalContext, conv chat1.ConversationLocal, myUsername string) string { 96 var name string 97 if conv.Info.Visibility == keybase1.TLFVisibility_PUBLIC { 98 name = publicConvNamePrefix + strings.Join(conv.ConvNameNames(), ",") 99 } else { 100 name = strings.Join(without(g, conv.ConvNameNames(), myUsername), ",") 101 if len(conv.ConvNameNames()) == 1 && conv.ConvNameNames()[0] == myUsername { 102 // The user is the only writer. 103 name = myUsername 104 } 105 } 106 if conv.Info.FinalizeInfo != nil { 107 name += " " + conv.Info.FinalizeInfo.BeforeSummary() 108 } 109 110 return name 111 } 112 113 // Make a name that looks like a tlfname but is sorted by activity and missing 114 // myUsername. 115 func ConvName(g *libkb.GlobalContext, conv chat1.ConversationLocal, myUsername string) string { 116 switch conv.GetMembersType() { 117 case chat1.ConversationMembersType_TEAM: 118 return convNameTeam(g, conv) 119 case chat1.ConversationMembersType_KBFS, chat1.ConversationMembersType_IMPTEAMNATIVE, 120 chat1.ConversationMembersType_IMPTEAMUPGRADE: 121 return convNameKBFS(g, conv, myUsername) 122 } 123 return "" 124 } 125 126 // Make a name that looks like a tlfname but is sorted by activity and missing myUsername. 127 // This is the less featureful version for convs that can't be unboxed. 128 func (v ConversationListView) convNameLite(g *libkb.GlobalContext, convErr chat1.ConversationErrorRekey, myUsername string) string { 129 var name string 130 if convErr.TlfPublic { 131 name = publicConvNamePrefix + strings.Join(convErr.WriterNames, ",") 132 } else { 133 name = strings.Join(without(g, convErr.WriterNames, myUsername), ",") 134 if len(convErr.WriterNames) == 1 && convErr.WriterNames[0] == myUsername { 135 // The user is the only writer. 136 name = myUsername 137 } 138 } 139 if len(convErr.ReaderNames) > 0 { 140 name += "#" + strings.Join(convErr.ReaderNames, ",") 141 } 142 143 return name 144 } 145 146 // When we hit identify failures looking up a conversation, we short-circuit 147 // before we get to parsing out readers and writers (which itself does more 148 // identifying). Instead we get an untrusted TLF name string, and we have the 149 // visibility. Cobble together a poor man's conversation name from those, by 150 // hacking out the current user's name. This should only be displayed next to 151 // an indication that it's unverified. 152 func formatUnverifiedConvName(unverifiedTLFName string, visibility keybase1.TLFVisibility, myUsername string) string { 153 // Strip the user's name out if it's got a comma next to it. (Two cases to 154 // handle: leading and trailing.) This both takes care of dangling commas, 155 // and preserves the user's name if it's by itself. 156 strippedTLFName := strings.ReplaceAll(unverifiedTLFName, ","+myUsername, "") 157 strippedTLFName = strings.ReplaceAll(strippedTLFName, myUsername+",", "") 158 if visibility == keybase1.TLFVisibility_PUBLIC { 159 return publicConvNamePrefix + strippedTLFName 160 } 161 return strippedTLFName 162 } 163 164 func without(g *libkb.GlobalContext, slice []string, el string) (res []string) { 165 for _, x := range slice { 166 if x != el { 167 res = append(res, x) 168 } 169 } 170 return res 171 } 172 173 func (v ConversationListView) Show(g *libkb.GlobalContext, myUsername string, showDeviceName bool) (err error) { 174 ui := g.UI.GetTerminalUI() 175 w, _ := ui.TerminalSize() 176 return v.RenderToWriter(g, ui.OutputWriter(), w, myUsername, showDeviceName, RenderOptions{}) 177 } 178 179 func (v ConversationListView) RenderToWriter(g *libkb.GlobalContext, writer io.Writer, width int, myUsername string, showDeviceName bool, opts RenderOptions) (err error) { 180 if len(v) == 0 { 181 fmt.Fprint(writer, "no conversations\n") 182 return nil 183 } 184 185 table := &flexibletable.Table{} 186 for i, conv := range v { 187 188 if conv.Error != nil { 189 unverifiedConvName := formatUnverifiedConvName(conv.Error.UnverifiedTLFName, conv.Info.Visibility, myUsername) 190 row := flexibletable.Row{ 191 flexibletable.Cell{ 192 Frame: [2]string{"[", "]"}, 193 Alignment: flexibletable.Right, 194 Content: flexibletable.SingleCell{Item: strconv.Itoa(i + 1)}, 195 }, 196 flexibletable.Cell{ // unread 197 Alignment: flexibletable.Center, 198 Content: flexibletable.SingleCell{Item: ""}, 199 }, 200 flexibletable.Cell{ 201 Alignment: flexibletable.Left, 202 Content: flexibletable.SingleCell{Item: "(unverified) " + unverifiedConvName}, 203 }, 204 flexibletable.Cell{ // authorAndTime 205 Frame: [2]string{"[", "]"}, 206 Alignment: flexibletable.Right, 207 Content: flexibletable.SingleCell{Item: "???"}, 208 }, 209 flexibletable.Cell{ // restrictedBotInfo 210 Alignment: flexibletable.Center, 211 Content: flexibletable.SingleCell{Item: "???"}, 212 }, 213 flexibletable.Cell{ // ephemeralInfo 214 Alignment: flexibletable.Center, 215 Content: flexibletable.SingleCell{Item: "???"}, 216 }, 217 flexibletable.Cell{ // reactionInfo 218 Alignment: flexibletable.Center, 219 Content: flexibletable.SingleCell{Item: "???"}, 220 }, 221 flexibletable.Cell{ 222 Alignment: flexibletable.Left, 223 Content: flexibletable.SingleCell{Item: conv.Error.Message}, 224 }, 225 } 226 227 if conv.Error.RekeyInfo != nil { 228 row[2].Content = flexibletable.SingleCell{Item: v.convNameLite(g, *conv.Error.RekeyInfo, myUsername)} 229 row[3].Content = flexibletable.SingleCell{Item: ""} 230 switch conv.Error.Typ { 231 case chat1.ConversationErrorType_SELFREKEYNEEDED: 232 row[4].Content = flexibletable.SingleCell{Item: "Rekey needed. Waiting for a participant to open their Keybase app."} 233 case chat1.ConversationErrorType_OTHERREKEYNEEDED: 234 row[4].Content = flexibletable.SingleCell{Item: "Rekey needed. Waiting for another participant to open their Keybase app."} 235 } 236 } 237 238 err := table.Insert(row) 239 if err != nil { 240 return err 241 } 242 continue 243 } 244 245 if conv.IsEmpty { 246 // Don't display empty conversations 247 continue 248 } 249 250 unread := "" 251 // Show the last visible message. 252 msg := conv.Info.SnippetMsg 253 if msg != nil && conv.ReaderInfo.ReadMsgid < msg.GetMessageID() { 254 unread = "*" 255 } 256 mv := newMessageViewNoMessages() 257 if msg != nil { 258 mv, err = newMessageView(g, opts, conv.Info.Id, *msg) 259 if err != nil { 260 g.Log.Error("Message render error: %s", err) 261 } 262 263 } else { 264 unread = "" 265 } 266 267 var authorAndTime string 268 if showDeviceName { 269 authorAndTime = mv.AuthorAndTimeWithDeviceName 270 } else { 271 authorAndTime = mv.AuthorAndTime 272 } 273 274 // This will show a blank link for convs whose last message cannot be displayed. 275 body := "" 276 if mv.Renderable { 277 body = mv.Body 278 } 279 280 err := table.Insert(flexibletable.Row{ 281 flexibletable.Cell{ 282 Frame: [2]string{"[", "]"}, 283 Alignment: flexibletable.Right, 284 Content: flexibletable.SingleCell{Item: strconv.Itoa(i + 1)}, 285 }, 286 flexibletable.Cell{ 287 Alignment: flexibletable.Center, 288 Content: flexibletable.SingleCell{Item: unread}, 289 }, 290 flexibletable.Cell{ 291 Alignment: flexibletable.Left, 292 Content: flexibletable.SingleCell{Item: ConvName(g, conv, myUsername)}, 293 }, 294 flexibletable.Cell{ 295 Frame: [2]string{"[", "]"}, 296 Alignment: flexibletable.Right, 297 Content: flexibletable.SingleCell{Item: authorAndTime}, 298 }, 299 flexibletable.Cell{ 300 Alignment: flexibletable.Center, 301 Content: flexibletable.SingleCell{Item: mv.RestrictedBotInfo}, 302 }, 303 flexibletable.Cell{ 304 Alignment: flexibletable.Center, 305 Content: flexibletable.SingleCell{Item: mv.EphemeralInfo}, 306 }, 307 flexibletable.Cell{ 308 Alignment: flexibletable.Center, 309 Content: flexibletable.SingleCell{Item: mv.ReactionInfo}, 310 }, 311 flexibletable.Cell{ 312 Alignment: flexibletable.Left, 313 Content: flexibletable.SingleCell{Item: body}, 314 }, 315 }) 316 if err != nil { 317 return err 318 } 319 } 320 321 if table.NumInserts() == 0 { 322 fmt.Fprint(writer, "no conversations\n") 323 return nil 324 } 325 326 if err := table.Render(writer, " ", width, []flexibletable.ColumnConstraint{ 327 15, // visualIndex 328 1, // unread 329 flexibletable.ColumnConstraint(width / 5), // convName 330 flexibletable.ColumnConstraint(width / 5), // authorAndTime 331 flexibletable.ColumnConstraint(width / 5), // RestrictedBotInfo 332 flexibletable.ColumnConstraint(width / 5), // ephemeralInfo 333 flexibletable.ColumnConstraint(width / 5), // reactionInfo 334 flexibletable.Expandable, // body 335 }); err != nil { 336 return fmt.Errorf("rendering conversation list view error: %v\n", err) 337 } 338 339 return nil 340 } 341 342 type RenderOptions struct { 343 UseDateTime bool 344 SkipHeadline bool 345 GetWalletClient func(g *libkb.GlobalContext) (cli stellar1.LocalClient, err error) 346 } 347 348 type ConversationView struct { 349 Conversation chat1.ConversationLocal 350 Messages []chat1.MessageUnboxed 351 Opts RenderOptions 352 } 353 354 func (v ConversationView) Show(g *libkb.GlobalContext, showDeviceName bool) error { 355 ui := g.UI.GetTerminalUI() 356 w, _ := ui.TerminalSize() 357 return v.RenderToWriter(g, ui.OutputWriter(), w, showDeviceName) 358 } 359 360 func (v ConversationView) RenderToWriter(g *libkb.GlobalContext, writer io.Writer, width int, showDeviceName bool) error { 361 if len(v.Messages) == 0 { 362 return nil 363 } 364 365 showRevokeAdvisory := false 366 367 headline := v.Conversation.Info.Headline 368 if headline != "" && !v.Opts.SkipHeadline { 369 fmt.Fprintf(writer, "headline: %s\n\n", headline) 370 } 371 372 table := &flexibletable.Table{} 373 for i := len(v.Messages) - 1; i >= 0; i-- { 374 m := v.Messages[i] 375 mv, err := newMessageView(g, v.Opts, v.Conversation.Info.Id, m) 376 if err != nil { 377 g.Log.Error("Message render error: %s", err) 378 } 379 380 if !mv.Renderable { 381 continue 382 } 383 384 if mv.FromRevokedDevice { 385 showRevokeAdvisory = true 386 } 387 388 unread := "" 389 if m.IsValid() && 390 v.Conversation.ReaderInfo.ReadMsgid < m.GetMessageID() { 391 unread = "*" 392 } 393 394 var authorAndTime string 395 if showDeviceName { 396 authorAndTime = mv.AuthorAndTimeWithDeviceName 397 } else { 398 authorAndTime = mv.AuthorAndTime 399 } 400 401 err = table.Insert(flexibletable.Row{ 402 flexibletable.Cell{ 403 Frame: [2]string{"[", "]"}, 404 Alignment: flexibletable.Right, 405 Content: flexibletable.SingleCell{Item: strconv.Itoa(int(mv.MessageID))}, 406 }, 407 flexibletable.Cell{ 408 Alignment: flexibletable.Center, 409 Content: flexibletable.SingleCell{Item: unread}, 410 }, 411 flexibletable.Cell{ 412 Frame: [2]string{"[", "]"}, 413 Alignment: flexibletable.Right, 414 Content: flexibletable.SingleCell{Item: authorAndTime}, 415 }, 416 flexibletable.Cell{ 417 Alignment: flexibletable.Center, 418 Content: flexibletable.SingleCell{Item: mv.RestrictedBotInfo}, 419 }, 420 flexibletable.Cell{ 421 Alignment: flexibletable.Center, 422 Content: flexibletable.SingleCell{Item: mv.EphemeralInfo}, 423 }, 424 flexibletable.Cell{ 425 Alignment: flexibletable.Center, 426 Content: flexibletable.SingleCell{Item: mv.ReactionInfo}, 427 }, 428 flexibletable.Cell{ 429 Alignment: flexibletable.Left, 430 Content: flexibletable.SingleCell{Item: mv.Body}, 431 }, 432 }) 433 if err != nil { 434 return err 435 } 436 } 437 if err := table.Render(writer, " ", width, []flexibletable.ColumnConstraint{ 438 15, // messageID 439 1, // unread 440 flexibletable.ColumnConstraint(width / 5), // authorAndTime 441 flexibletable.ColumnConstraint(width / 5), // restrictedBotInfo 442 flexibletable.ColumnConstraint(width / 5), // ephemeralInfo 443 flexibletable.ColumnConstraint(width / 5), // reactionInfo 444 flexibletable.ExpandableWrappable, // body 445 }); err != nil { 446 return fmt.Errorf("rendering conversation view error: %v\n", err) 447 } 448 449 if showRevokeAdvisory { 450 fmt.Fprint(writer, "\nNote: Messages with (!) next to the sender were sent from a device that is now revoked.\n") 451 } 452 453 return nil 454 } 455 456 // Everything you need to show a message. 457 // Takes into account superseding edits and deletions. 458 type messageView struct { 459 MessageID chat1.MessageID 460 461 // Whether to show this message. Show texts, but not edits or deletes. 462 Renderable bool 463 AuthorAndTime string 464 AuthorAndTimeWithDeviceName string 465 Body string 466 EphemeralInfo string 467 RestrictedBotInfo string 468 ReactionInfo string 469 FromRevokedDevice bool 470 471 // Used internally for supersedeers 472 messageType chat1.MessageType 473 } 474 475 func formatSystemMessage(body chat1.MessageSystem) string { 476 m := body.String() 477 if m == "" { 478 return "<unknown system message>" 479 } 480 return fmt.Sprintf("[%s]", m) 481 } 482 483 func formatSendPaymentMessage(g *libkb.GlobalContext, opts RenderOptions, body chat1.MessageSendPayment) string { 484 ctx := context.Background() 485 if opts.GetWalletClient == nil { 486 return fmt.Sprintf("<paymentID %s>", body.PaymentID) 487 } 488 489 cli, err := opts.GetWalletClient(g) 490 if err != nil { 491 g.Log.CDebugf(ctx, "GetWalletClient() error: %s", err) 492 return "[error getting payment details]" 493 } 494 details, err := cli.PaymentDetailCLILocal(ctx, stellar1.TransactionIDFromPaymentID(body.PaymentID).String()) 495 if err != nil { 496 g.Log.CDebugf(ctx, "PaymentDetailCLILocal() error: %s", err) 497 return "[error getting payment details]" 498 } 499 500 var verb string 501 statusStr := strings.ToLower(details.Status) 502 switch statusStr { 503 case "completed", "claimable": 504 verb = "sent" 505 case "canceled": 506 verb = "canceled sending" 507 case "pending": 508 verb = "sending" 509 default: 510 return fmt.Sprintf("error sending payment: %s %s", details.Status, details.StatusDetail) 511 } 512 513 amountXLM := fmt.Sprintf("%s XLM", libkb.StellarSimplifyAmount(details.Amount)) 514 515 var amountDescription string 516 if details.DisplayAmount != nil && details.DisplayCurrency != nil && len(*details.DisplayAmount) > 0 && len(*details.DisplayAmount) > 0 { 517 amountDescription = fmt.Sprintf("Lumens worth %s %s (%s)", *details.DisplayAmount, *details.DisplayCurrency, amountXLM) 518 } else { 519 amountDescription = amountXLM 520 } 521 522 view := verb + " " + amountDescription 523 if statusStr == "claimable" { 524 // e.g. "Waiting for the recipient to open the app to claim, or the sender to cancel." 525 view += fmt.Sprintf("\n%s", details.StatusDetail) 526 } 527 if details.Note != "" { 528 view += "\n> " + details.Note 529 } 530 531 return view 532 } 533 534 func formatRequestPaymentMessage(g *libkb.GlobalContext, opts RenderOptions, body chat1.MessageRequestPayment) (view string) { 535 if opts.GetWalletClient == nil { 536 return fmt.Sprintf("<reqeustID %s>", body.RequestID) 537 } 538 539 const formattingErrorStr = "[error getting request details]" 540 ctx := context.Background() 541 542 cli, err := opts.GetWalletClient(g) 543 if err != nil { 544 g.Log.CDebugf(ctx, "GetWalletClient() error: %s", err) 545 return formattingErrorStr 546 } 547 548 details, err := cli.GetRequestDetailsLocal(ctx, stellar1.GetRequestDetailsLocalArg{ 549 ReqID: body.RequestID, 550 }) 551 if err != nil { 552 g.Log.CDebugf(ctx, "GetRequestDetailsLocal failed with: %s", err) 553 return formattingErrorStr 554 } 555 556 if details.Currency != nil { 557 view = fmt.Sprintf("requested Lumens worth %s", details.AmountDescription) 558 } else { 559 view = fmt.Sprintf("requested %s", details.AmountDescription) 560 } 561 562 if len(body.Note) > 0 { 563 view += "\n> " + body.Note 564 } 565 566 if details.Status == stellar1.RequestStatus_CANCELED { 567 // If canceled, add "[canceled]" prefix. 568 view = "[canceled] " + view 569 } else { 570 // If not, append request ID for cancel-request command. 571 view += fmt.Sprintf("\n[Request ID: %s]", body.RequestID) 572 } 573 574 return view 575 } 576 577 func newMessageViewValid(g *libkb.GlobalContext, opts RenderOptions, conversationID chat1.ConversationID, m chat1.MessageUnboxedValid) (mv messageView, err error) { 578 mv.MessageID = m.ServerHeader.MessageID 579 mv.FromRevokedDevice = m.SenderDeviceRevokedAt != nil 580 581 body := m.MessageBody 582 typ, err := body.MessageType() 583 mv.messageType = typ 584 if err != nil { 585 return mv, err 586 } 587 switch typ { 588 case chat1.MessageType_NONE: 589 // NONE is what you get when a message has been deleted. 590 mv.Renderable = true 591 mv.Body = "[deleted]" 592 case chat1.MessageType_TEXT: 593 mv.Renderable = true 594 mv.Body = body.Text().Body 595 if m.ServerHeader.SupersededBy > 0 { 596 mv.Body += " (edited)" 597 } 598 case chat1.MessageType_ATTACHMENT: 599 mv.Renderable = true 600 att := body.Attachment() 601 mv.Body = fmt.Sprintf("%s <attachment ID: %d>", att.GetTitle(), m.ServerHeader.MessageID) 602 if len(att.Previews) > 0 { 603 mv.Body += " [preview available]" 604 } 605 if att.Uploaded { 606 mv.Body += " (uploaded)" 607 } else { 608 mv.Body += " (...)" 609 } 610 if m.ServerHeader.SupersededBy > 0 { 611 mv.Body += " (edited)" 612 } 613 case chat1.MessageType_EDIT: 614 mv.Renderable = false 615 // Return the edit body for display in the original 616 mv.Body = fmt.Sprintf("%v [edited]", body.Edit().Body) 617 case chat1.MessageType_DELETE: 618 mv.Renderable = false 619 case chat1.MessageType_METADATA: 620 mv.Renderable = false 621 case chat1.MessageType_TLFNAME: 622 mv.Renderable = false 623 case chat1.MessageType_HEADLINE: 624 mv.Renderable = true 625 mv.Body = fmt.Sprintf("[%s]", m.MessageBody.Headline()) 626 case chat1.MessageType_ATTACHMENTUPLOADED: 627 mv.Renderable = false 628 case chat1.MessageType_JOIN: 629 mv.Renderable = true 630 mv.Body = "[Joined the channel]" 631 case chat1.MessageType_LEAVE: 632 mv.Renderable = true 633 mv.Body = "[Left the channel]" 634 case chat1.MessageType_SYSTEM: 635 mv.Renderable = true 636 mv.Body = formatSystemMessage(m.MessageBody.System()) 637 case chat1.MessageType_DELETEHISTORY: 638 mv.Renderable = false 639 case chat1.MessageType_REACTION: 640 mv.Renderable = false 641 case chat1.MessageType_SENDPAYMENT: 642 mv.Renderable = true 643 mv.Body = formatSendPaymentMessage(g, opts, m.MessageBody.Sendpayment()) 644 case chat1.MessageType_REQUESTPAYMENT: 645 mv.Renderable = true 646 mv.Body = formatRequestPaymentMessage(g, opts, m.MessageBody.Requestpayment()) 647 case chat1.MessageType_UNFURL: 648 mv.Renderable = false 649 case chat1.MessageType_FLIP: 650 mv.Renderable = true 651 mv.Body = m.MessageBody.Flip().Text 652 case chat1.MessageType_PIN: 653 mv.Renderable = false 654 default: 655 return mv, fmt.Errorf(fmt.Sprintf("unsupported MessageType: %s", typ.String())) 656 } 657 658 possiblyRevokedMark := "" 659 if mv.FromRevokedDevice { 660 possiblyRevokedMark = "(!)" 661 } 662 t := gregor1.FromTime(m.ServerHeader.Ctime) 663 mv.AuthorAndTime = fmt.Sprintf("%s%s %s", 664 m.SenderUsername, possiblyRevokedMark, FmtTime(t, opts)) 665 mv.AuthorAndTimeWithDeviceName = fmt.Sprintf("%s%s <%s> %s", 666 m.SenderUsername, possiblyRevokedMark, m.SenderDeviceName, FmtTime(t, opts)) 667 668 if m.IsEphemeral() { 669 if m.IsEphemeralExpired(time.Now()) { 670 var explodedByText string 671 if m.ExplodedBy() != nil { 672 explodedByText = fmt.Sprintf(" by %s", *m.ExplodedBy()) 673 } 674 mv.Body = fmt.Sprintf("[exploded%s] ", explodedByText) 675 for i := 0; i < 40; i++ { 676 mv.Body += "* " 677 } 678 } else { 679 remainingEphemeralLifetime := m.RemainingEphemeralLifetime(time.Now()) 680 mv.EphemeralInfo = fmt.Sprintf("[expires in %s]", remainingEphemeralLifetime) 681 } 682 } 683 if m.BotUsername != "" { 684 mv.RestrictedBotInfo = fmt.Sprintf("[encrypted for bot @%s]", m.BotUsername) 685 } 686 687 // sort reactions so the ordering is stable when rendering 688 reactionTexts := []string{} 689 for reactionText := range m.Reactions.Reactions { 690 reactionTexts = append(reactionTexts, reactionText) 691 } 692 sort.Strings(reactionTexts) 693 694 var reactionInfo string 695 for _, reactionText := range reactionTexts { 696 reactions := m.Reactions.Reactions[reactionText] 697 reactionInfo += emoji.Sprintf("%v[%d] ", reactionText, len(reactions)) 698 } 699 mv.ReactionInfo = reactionInfo 700 701 return mv, nil 702 } 703 704 func outboxStateView(state chat1.OutboxState, body string) string { 705 var ststr string 706 st, err := state.State() 707 if err != nil { 708 return "<unknown state>" 709 } 710 switch st { 711 case chat1.OutboxStateType_SENDING: 712 ststr = "<sending>" 713 case chat1.OutboxStateType_ERROR: 714 ststr = "<error>" 715 } 716 717 return fmt.Sprintf("[outbox message: state: %s contents: %s]", ststr, body) 718 } 719 720 func newMessageViewOutbox(g *libkb.GlobalContext, opts RenderOptions, conversationID chat1.ConversationID, m chat1.OutboxRecord) (mv messageView, err error) { 721 722 body := m.Msg.MessageBody 723 typ, err := body.MessageType() 724 mv.messageType = typ 725 if err != nil { 726 return mv, err 727 } 728 switch typ { 729 case chat1.MessageType_TEXT: 730 mv.Body = m.Msg.MessageBody.Text().Body 731 mv.Renderable = true 732 case chat1.MessageType_ATTACHMENT: 733 // TODO: fix me? 734 mv.Body = "<attachment>" 735 mv.Renderable = true 736 case chat1.MessageType_EDIT: 737 mv.Body = fmt.Sprintf("<edit message: %s>", m.Msg.MessageBody.Edit().Body) 738 mv.Renderable = true 739 case chat1.MessageType_DELETE: 740 mv.Body = "<delete message>" 741 mv.Renderable = true 742 default: 743 mv.Body = "<unknown message type>" 744 mv.Renderable = true 745 } 746 mv.Body = outboxStateView(m.State, mv.Body) 747 748 t := gregor1.FromTime(m.Ctime) 749 username := g.Env.GetUsername().String() 750 mv.FromRevokedDevice = false 751 mv.MessageID = m.Msg.ClientHeader.OutboxInfo.Prev 752 mv.AuthorAndTime = fmt.Sprintf("%s %s", username, FmtTime(t, opts)) 753 mv.AuthorAndTimeWithDeviceName = fmt.Sprintf("%s <current> %s", username, FmtTime(t, opts)) 754 755 return mv, nil 756 } 757 758 func newMessageViewError(g *libkb.GlobalContext, opts RenderOptions, conversationID chat1.ConversationID, 759 m chat1.MessageUnboxedError) (mv messageView, err error) { 760 761 mv.messageType = m.MessageType 762 mv.Renderable = true 763 mv.FromRevokedDevice = false 764 mv.MessageID = m.MessageID 765 mv.AuthorAndTime = "???" 766 mv.AuthorAndTimeWithDeviceName = "???" 767 768 critVersion := false 769 switch m.ErrType { 770 case chat1.MessageUnboxedErrorType_BADVERSION_CRITICAL: 771 critVersion = true 772 fallthrough 773 case chat1.MessageUnboxedErrorType_BADVERSION: 774 mv.Body = fmt.Sprintf("<chat read error: invalid message version (critical: %v)>", critVersion) 775 default: 776 mv.Body = fmt.Sprintf("<chat read error: %s>", m.ErrMsg) 777 } 778 779 return mv, nil 780 } 781 782 func newMessageViewNoMessages() (mv messageView) { 783 return messageView{ 784 Renderable: true, 785 Body: "<no messages>", 786 } 787 } 788 789 // newMessageView extracts from a message the parts for display 790 // It may fetch the superseding message. So that for example a TEXT message will show its EDIT text. 791 func newMessageView(g *libkb.GlobalContext, opts RenderOptions, conversationID chat1.ConversationID, m chat1.MessageUnboxed) (mv messageView, err error) { 792 defer func() { mv.Body = emoji.Sprint(mv.Body) }() 793 state, err := m.State() 794 if err != nil { 795 return mv, fmt.Errorf("unexpected empty message") 796 } 797 switch state { 798 case chat1.MessageUnboxedState_ERROR: 799 return newMessageViewError(g, opts, conversationID, m.Error()) 800 case chat1.MessageUnboxedState_OUTBOX: 801 return newMessageViewOutbox(g, opts, conversationID, m.Outbox()) 802 case chat1.MessageUnboxedState_VALID: 803 return newMessageViewValid(g, opts, conversationID, m.Valid()) 804 default: 805 return mv, fmt.Errorf("unexpected message state: %v", state) 806 } 807 808 } 809 810 func FmtTime(t time.Time, opts RenderOptions) string { 811 if opts.UseDateTime { 812 // In go>=1.20 this is time.DateTime 813 return t.Format("2006-01-02 15:04:05") 814 } 815 return ShortDurationFromNow(t) 816 } 817 818 func ShortDurationFromNow(t time.Time) string { 819 d := time.Since(t) 820 821 num := d.Hours() / 24 822 if num > 1 { 823 return strconv.Itoa(int(math.Ceil(num))) + "d" 824 } 825 826 num = d.Hours() 827 if num > 1 { 828 return strconv.Itoa(int(math.Ceil(num))) + "h" 829 } 830 831 num = d.Minutes() 832 if num > 1 { 833 return strconv.Itoa(int(math.Ceil(num))) + "m" 834 } 835 836 num = d.Seconds() 837 return strconv.Itoa(int(math.Ceil(num))) + "s" 838 }