Localize rich-text message preview labels; split audio into voice/music

Thread PresentationStrings through RichText/InstantPageBlock/InstantPage
previewText(), replacing the hardcoded //TODO:localize placeholders
("Photo", "Fx", "Table", "Map", ...) with localized keys and reusing the
existing Message.Photo/Video/Location strings. Add RichTextPreview.Formula
("[formula]"), RichTextPreview.Table ("[table]"), and RichTextPreview.Music
("Music").

The .audio block previously rendered the wrong label (Message.Audio is
"Voice Message"). Thread InstantPage.media down so the block can resolve
media[id] as TelegramMediaFile and split: isVoice -> "Voice Message",
otherwise -> "Music", mirroring MessageContentKind's voice/music handling.

Update both previewText() call sites (MessageContentKind, ChatListItemStrings)
to pass strings, and complete the InstantPageListItem migration that was
left on the old signature.

Also remove the dead streaming-status ("Thinking...") rendering block from
ChatMessageTextBubbleContentNode (guarded by an always-false `!"".isEmpty`)
along with the now-orphaned streamingTextFrame layout machinery it fed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
isaac 2026-06-05 00:18:24 +02:00
parent 0050cc7a08
commit 35711ec6ad
5 changed files with 57 additions and 160 deletions

View file

@ -16369,3 +16369,7 @@ Error: %8$@";
"WebBrowser.Exceptions.DontOpenInApp" = "DON'T OPEN IN-APP";
"WebBrowser.Exceptions.InAppInfo" = "These sites will still be opened in-app.";
"WebBrowser.Exceptions.DeleteAll" = "Delete All Exceptions";
"RichTextPreview.Formula" = "[formula]";
"RichTextPreview.Table" = "[table]";
"RichTextPreview.Music" = "Music";

View file

@ -104,7 +104,7 @@ public func chatListItemStrings(strings: PresentationStrings, nameDisplayOrder:
messageText = ""
for message in messages {
if let richText = message.richText {
messageText = richText.instantPage.previewText()
messageText = richText.instantPage.previewText(strings: strings)
messageEntities = []
} else if !message.text.isEmpty {
messageText = message.text

View file

@ -1,64 +1,64 @@
import Foundation
import Postbox
import TelegramCore
import TelegramPresentationData
extension RichText {
public func previewText() -> String {
public func previewText(strings: PresentationStrings) -> String {
switch self {
case .empty:
return ""
case let .plain(value):
return value
case let .bold(value):
return value.previewText()
return value.previewText(strings: strings)
case let .italic(value):
return value.previewText()
return value.previewText(strings: strings)
case let .underline(value):
return value.previewText()
return value.previewText(strings: strings)
case let .strikethrough(value):
return value.previewText()
return value.previewText(strings: strings)
case let .fixed(value):
return value.previewText()
return value.previewText(strings: strings)
case let .url(value, _, _):
return value.previewText()
return value.previewText(strings: strings)
case let .email(value, _):
return value.previewText()
return value.previewText(strings: strings)
case let .concat(values):
var result = ""
for value in values {
result.append(value.previewText())
result.append(value.previewText(strings: strings))
}
return result
case let .`subscript`(value):
return value.previewText()
return value.previewText(strings: strings)
case let .superscript(value):
return value.previewText()
return value.previewText(strings: strings)
case let .marked(value):
return value.previewText()
return value.previewText(strings: strings)
case let .phone(value, _):
return value.previewText()
return value.previewText(strings: strings)
case .image:
//TODO:localize
return "Photo"
return strings.Message_Photo
case let .anchor(value, _):
return value.previewText()
return value.previewText(strings: strings)
case .formula:
//TODO:localize
return "Fx"
return strings.RichTextPreview_Formula
case let .textCustomEmoji(_, alt):
return alt
case let .textAutoEmail(value), let .textAutoPhone(value), let .textAutoUrl(value), let .textBankCard(value), let .textBotCommand(value), let .textCashtag(value), let .textHashtag(value), let .textMention(value), let .textMentionName(value, _), let .textSpoiler(value), let .textDate(value, _, _):
return value.previewText()
return value.previewText(strings: strings)
}
}
}
extension InstantPageListItem {
public func previewText() -> String {
public func previewText(strings: PresentationStrings, media: [MediaId: Media]) -> String {
switch self {
case .unknown:
return ""
case let .text(text, num, checked):
let body = text.previewText()
let body = text.previewText(strings: strings)
if let checked {
return "\(checked ? "☑︎" : "") \(body)"
} else if let num, !num.isEmpty {
@ -72,7 +72,7 @@ extension InstantPageListItem {
if !blocksText.isEmpty {
blocksText.append("\n")
}
blocksText.append(block.previewText())
blocksText.append(block.previewText(strings: strings, media: media))
}
if let checked {
return "\(checked ? "☑︎" : "") \(blocksText)"
@ -86,30 +86,30 @@ extension InstantPageListItem {
}
extension InstantPageBlock {
public func previewText() -> String {
public func previewText(strings: PresentationStrings, media: [MediaId: Media]) -> String {
switch self {
case .unsupported:
return ""
case let .title(text):
return text.previewText()
return text.previewText(strings: strings)
case let .subtitle(text):
return text.previewText()
return text.previewText(strings: strings)
case let .authorDate(author, _):
return author.previewText()
return author.previewText(strings: strings)
case let .header(text):
return text.previewText()
return text.previewText(strings: strings)
case let .subheader(text):
return text.previewText()
return text.previewText(strings: strings)
case let .heading(text, _):
return text.previewText()
return text.previewText(strings: strings)
case .formula:
return "Fx"
return strings.RichTextPreview_Formula
case let .paragraph(text):
return text.previewText()
return text.previewText(strings: strings)
case let .preformatted(text, _):
return text.previewText()
return text.previewText(strings: strings)
case let .footer(text):
return text.previewText()
return text.previewText(strings: strings)
case .divider:
return "\n"
case .anchor:
@ -120,23 +120,24 @@ extension InstantPageBlock {
if !result.isEmpty {
result.append("\n")
}
result.append(item.previewText())
result.append(item.previewText(strings: strings, media: media))
}
return result
case let .blockQuote(blocks, caption):
let body = blocks.map { $0.previewText() }.joined(separator: " ")
return body + caption.previewText()
let body = blocks.map { $0.previewText(strings: strings, media: media) }.joined(separator: " ")
return body + caption.previewText(strings: strings)
case let .pullQuote(text, caption):
return text.previewText() + caption.previewText()
return text.previewText(strings: strings) + caption.previewText(strings: strings)
case .image(_, _, _, _):
//TODO:localize
return "Photo"
return strings.Message_Photo
case .video(_, _, _, _):
//TODO:localize
return "Video"
case .audio:
//TODO:localize
return "Audio"
return strings.Message_Video
case let .audio(id, _):
if let file = media[id] as? TelegramMediaFile, file.isVoice {
return strings.Message_Audio
} else {
return strings.RichTextPreview_Music
}
case .cover:
return ""
case .webEmbed:
@ -154,28 +155,26 @@ extension InstantPageBlock {
case .thinking:
return ""
case .table:
//TODO:localize
return "Table"
return strings.RichTextPreview_Table
case .details:
return ""
case .relatedArticles:
return ""
case .map:
//TODO:localize
return "Map"
return strings.Message_Location
}
}
}
extension InstantPage {
public func previewText() -> String {
public func previewText(strings: PresentationStrings) -> String {
let maxLength: Int = 200
var result = ""
for block in self.blocks {
if !result.isEmpty {
result.append("\n")
}
result.append(block.previewText())
result.append(block.previewText(strings: strings, media: self.media))
if result.count > maxLength {
break
}

View file

@ -314,7 +314,7 @@ public func messageContentKind(contentSettings: ContentSettings, message: Engine
}
for attribute in message.attributes {
if let attribute = attribute as? RichTextMessageAttribute {
return .text(NSAttributedString(string: attribute.instantPage.previewText()))
return .text(NSAttributedString(string: attribute.instantPage.previewText(strings: strings)))
}
}
return .text(messageTextWithAttributes(message: message))

View file

@ -88,8 +88,6 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode {
private let containerNode: ContainerNode
private let textNode: InteractiveTextNodeWithEntities
private var streamingStatusTextNode: InteractiveTextNodeWithEntities?
private var streamingStatusShimmerView: ShimmeringMaskView?
private let textAccessibilityOverlayNode: TextAccessibilityOverlayNode
public var statusNode: ChatMessageDateAndStatusNode?
@ -218,7 +216,6 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode {
let previousItem = self.item
let textLayout = InteractiveTextNodeWithEntities.asyncLayout(self.textNode)
let streamingStatusTextLayout = InteractiveTextNodeWithEntities.asyncLayout(self.streamingStatusTextNode)
let statusLayout = ChatMessageDateAndStatusNode.asyncLayout(self.statusNode)
let currentCachedChatMessageText = self.cachedChatMessageText
@ -697,24 +694,6 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode {
computeCharacterRects: true
))
var streamingTextLayoutAndApply: (layout: InteractiveTextNodeLayout, apply: (InteractiveTextNodeWithEntities.Arguments) -> InteractiveTextNodeWithEntities)?
if !"".isEmpty && (hasDraft || hadDraft) {
//TODO:localize
streamingTextLayoutAndApply = streamingStatusTextLayout(InteractiveTextNodeLayoutArguments(
attributedString: NSAttributedString(string: "Thinking...", font: textFont, textColor: messageTheme.fileDescriptionColor),
backgroundColor: nil,
maximumNumberOfLines: 1,
truncationType: .end,
constrainedSize: textConstrainedSize,
alignment: .natural,
cutout: nil,
insets: textInsets,
lineColor: messageTheme.accentControlColor,
customTruncationToken: customTruncationToken,
computeCharacterRects: true
))
}
var maxGlyphCount = currentMaxGlyphCount
if maxGlyphCount == nil && (hasDraft || hadDraft) {
maxGlyphCount = previousGlyphCount
@ -775,16 +754,7 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode {
}
var textFrame = CGRect(origin: CGPoint(x: -textInsets.left, y: -textInsets.top), size: textLayout.size)
let streamingTextSpacing: CGFloat = 1.0
var streamingTextFrame: CGRect?
if let streamingTextLayoutAndApply {
let streamingTextFrameValue = CGRect(origin: CGPoint(x: layoutConstants.text.bubbleInsets.left - textInsets.left, y: topInset - textInsets.top), size: streamingTextLayoutAndApply.layout.size)
streamingTextFrame = streamingTextFrameValue
textFrame.origin.y += streamingTextFrameValue.height + streamingTextSpacing - textInsets.top - textInsets.bottom
}
var textFrameWithoutInsets = CGRect(origin: CGPoint(x: textFrame.origin.x + textInsets.left, y: textFrame.origin.y + textInsets.top), size: CGSize(width: textFrame.width - textInsets.left - textInsets.right, height: textFrame.height - textInsets.top - textInsets.bottom))
textFrame = textFrame.offsetBy(dx: layoutConstants.text.bubbleInsets.left, dy: topInset)
@ -798,9 +768,6 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode {
textFrameWithoutInsets = textFrameWithoutInsets.offsetBy(dx: layoutConstants.text.bubbleInsets.left, dy: topInset)
var suggestedBoundingWidth: CGFloat = textFrameWithoutInsets.width
if let streamingTextFrame {
suggestedBoundingWidth = max(suggestedBoundingWidth, streamingTextFrame.width - textInsets.left - textInsets.right)
}
if let statusSuggestedWidthAndContinue = statusSuggestedWidthAndContinue, !hasDraft {
suggestedBoundingWidth = max(suggestedBoundingWidth, statusSuggestedWidthAndContinue.0)
}
@ -816,12 +783,7 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode {
if let statusSizeAndApply = statusSizeAndApply, !hasDraft {
boundingSize.height += statusSizeAndApply.0.height
}
if let streamingTextFrame {
boundingSize.width = max(boundingSize.width, streamingTextFrame.width - textInsets.left - textInsets.right)
boundingSize.height += streamingTextFrame.height - textInsets.top - textInsets.bottom + streamingTextSpacing
}
boundingSize.width += layoutConstants.text.bubbleInsets.left + layoutConstants.text.bubbleInsets.right
boundingSize.height += topInset + bottomInset
@ -927,74 +889,6 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode {
animation.animator.updatePosition(layer: strongSelf.textNode.textNode.layer, position: realTextFrame.center, completion: nil)
animation.animator.updateBounds(layer: strongSelf.textNode.textNode.layer, bounds: CGRect(origin: CGPoint(), size: realTextFrame.size), completion: nil)
if let streamingTextFrame, let streamingTextLayoutAndApply {
var animation = animation
if strongSelf.streamingStatusTextNode == nil {
animation = .None
}
let streamingStatusTextNode = streamingTextLayoutAndApply.apply(InteractiveTextNodeWithEntities.Arguments(
context: item.context,
cache: item.controllerInteraction.presentationContext.animationCache,
renderer: item.controllerInteraction.presentationContext.animationRenderer,
placeholderColor: messageTheme.mediaPlaceholderColor,
attemptSynchronous: synchronousLoads,
textColor: messageTheme.primaryTextColor,
spoilerEffectColor: messageTheme.secondaryTextColor,
applyArguments: InteractiveTextNode.ApplyArguments(
animation: animation,
spoilerTextColor: messageTheme.primaryTextColor,
spoilerEffectColor: messageTheme.secondaryTextColor,
areContentAnimationsEnabled: item.context.sharedContext.energyUsageSettings.loopEmoji,
spoilerExpandRect: nil,
crossfadeContents: { [weak strongSelf] sourceView in
guard let strongSelf, let streamingStatusTextNode = strongSelf.streamingStatusTextNode else {
return
}
if let shimmerView = strongSelf.streamingStatusShimmerView {
sourceView.frame = CGRect(origin: streamingStatusTextNode.textNode.frame.origin, size: sourceView.bounds.size)
shimmerView.addSubview(sourceView)
sourceView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.12, removeOnCompletion: false, completion: { [weak sourceView] _ in
sourceView?.removeFromSuperview()
})
streamingStatusTextNode.textNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.1)
}
}
)
))
let streamingStatusShimmerView: ShimmeringMaskView
if let current = strongSelf.streamingStatusShimmerView {
streamingStatusShimmerView = current
} else {
streamingStatusShimmerView = ShimmeringMaskView(peakAlpha: 0.3, duration: 1.0)
strongSelf.streamingStatusShimmerView = streamingStatusShimmerView
strongSelf.containerNode.view.addSubview(streamingStatusShimmerView)
}
if streamingStatusTextNode !== strongSelf.streamingStatusTextNode {
strongSelf.streamingStatusTextNode?.textNode.view.removeFromSuperview()
strongSelf.streamingStatusTextNode = streamingStatusTextNode
streamingStatusShimmerView.contentView.addSubview(streamingStatusTextNode.textNode.view)
}
animation.animator.updatePosition(layer: streamingStatusShimmerView.layer, position: streamingTextFrame.center, completion: nil)
animation.animator.updateBounds(layer: streamingStatusShimmerView.layer, bounds: CGRect(origin: CGPoint(), size: streamingTextFrame.size), completion: nil)
animation.animator.updatePosition(layer: streamingStatusTextNode.textNode.layer, position: CGPoint(x: streamingTextFrame.size.width * 0.5, y: streamingTextFrame.size.height * 0.5), completion: nil)
animation.animator.updateBounds(layer: streamingStatusTextNode.textNode.layer, bounds: CGRect(origin: CGPoint(), size: streamingTextFrame.size), completion: nil)
streamingStatusShimmerView.update(
size: streamingTextFrame.size,
containerWidth: streamingTextFrame.size.width,
offsetX: 0.0,
gradientWidth: 200.0,
transition: .immediate
)
} else if let streamingStatusShimmerView = strongSelf.streamingStatusShimmerView {
strongSelf.streamingStatusTextNode = nil
strongSelf.streamingStatusShimmerView = nil
animation.animator.updateAlpha(layer: streamingStatusShimmerView.layer, alpha: 0.0, completion: { [weak streamingStatusShimmerView] _ in
streamingStatusShimmerView?.removeFromSuperview()
})
}
switch strongSelf.visibility {
case .none: