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