core, ui, website: small text markdown (#6697)

* core: small text markdown

* ios: small markdown

* desktop, android: small markdown

* fix font size

* small markdown on website

* update ios core library

* update bot api docs

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
This commit is contained in:
Evgeny @ SimpleX Chat
2026-03-21 18:06:23 +00:00
committed by GitHub
co-authored by Evgeny Poberezkin
parent 2472bee6b6
commit a8a8883027
13 changed files with 143 additions and 14 deletions
@@ -322,6 +322,7 @@ func messageText(
var bold: UIFont?
var italic: UIFont?
var snippet: UIFont?
var small: UIFont?
var mention: UIFont?
var secretIdx: Int = 0
for ft in fts {
@@ -353,6 +354,10 @@ func messageText(
attrs[.backgroundColor] = secretColor
}
hasSecrets = true
case .small:
small = small ?? UIFont.preferredFont(forTextStyle: .footnote)
attrs[.font] = small
attrs[.foregroundColor] = UIColor.secondaryLabel
case let .colored(color):
if let c = color.uiColor {
attrs[.foregroundColor] = UIColor(c)
+8 -8
View File
@@ -178,8 +178,8 @@
64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */; };
64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829982D54AEED006B9E89 /* libgmp.a */; };
64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829992D54AEEE006B9E89 /* libffi.a */; };
64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.9-3esYZFBUokREq84LvcOgzJ-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.9-3esYZFBUokREq84LvcOgzJ-ghc9.6.3.a */; };
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.9-3esYZFBUokREq84LvcOgzJ.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.9-3esYZFBUokREq84LvcOgzJ.a */; };
64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.9-IckAKQLBKZZ3c4EBa1qhzo-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.9-IckAKQLBKZZ3c4EBa1qhzo-ghc9.6.3.a */; };
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.9-IckAKQLBKZZ3c4EBa1qhzo.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.9-IckAKQLBKZZ3c4EBa1qhzo.a */; };
64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */; };
64D0C2C029F9688300B38D5F /* UserAddressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */; };
64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */; };
@@ -545,8 +545,8 @@
64C3B0202A0D359700E19930 /* CustomTimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimePicker.swift; sourceTree = "<group>"; };
64C829982D54AEED006B9E89 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
64C829992D54AEEE006B9E89 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.9-3esYZFBUokREq84LvcOgzJ-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.0.9-3esYZFBUokREq84LvcOgzJ-ghc9.6.3.a"; sourceTree = "<group>"; };
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.9-3esYZFBUokREq84LvcOgzJ.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.0.9-3esYZFBUokREq84LvcOgzJ.a"; sourceTree = "<group>"; };
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.9-IckAKQLBKZZ3c4EBa1qhzo-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.0.9-IckAKQLBKZZ3c4EBa1qhzo-ghc9.6.3.a"; sourceTree = "<group>"; };
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.9-IckAKQLBKZZ3c4EBa1qhzo.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.0.9-IckAKQLBKZZ3c4EBa1qhzo.a"; sourceTree = "<group>"; };
64C8299C2D54AEEE006B9E89 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
64D0C2BF29F9688300B38D5F /* UserAddressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressView.swift; sourceTree = "<group>"; };
64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressLearnMore.swift; sourceTree = "<group>"; };
@@ -708,8 +708,8 @@
64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */,
64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */,
64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */,
64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.9-3esYZFBUokREq84LvcOgzJ-ghc9.6.3.a in Frameworks */,
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.9-3esYZFBUokREq84LvcOgzJ.a in Frameworks */,
64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.9-IckAKQLBKZZ3c4EBa1qhzo-ghc9.6.3.a in Frameworks */,
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.9-IckAKQLBKZZ3c4EBa1qhzo.a in Frameworks */,
CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -795,8 +795,8 @@
64C829992D54AEEE006B9E89 /* libffi.a */,
64C829982D54AEED006B9E89 /* libgmp.a */,
64C8299C2D54AEEE006B9E89 /* libgmpxx.a */,
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.9-3esYZFBUokREq84LvcOgzJ-ghc9.6.3.a */,
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.9-3esYZFBUokREq84LvcOgzJ.a */,
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.9-IckAKQLBKZZ3c4EBa1qhzo-ghc9.6.3.a */,
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.9-IckAKQLBKZZ3c4EBa1qhzo.a */,
);
path = Libraries;
sourceTree = "<group>";
+1
View File
@@ -4663,6 +4663,7 @@ public enum Format: Decodable, Equatable, Hashable {
case strikeThrough
case snippet
case secret
case small
case colored(color: FormatColor)
case uri
case hyperLink(showText: String?, linkUri: String)
@@ -4363,6 +4363,7 @@ sealed class Format {
@Serializable @SerialName("strikeThrough") class StrikeThrough: Format()
@Serializable @SerialName("snippet") class Snippet: Format()
@Serializable @SerialName("secret") class Secret: Format()
@Serializable @SerialName("small") class Small: Format()
@Serializable @SerialName("colored") class Colored(val color: FormatColor): Format()
@Serializable @SerialName("uri") class Uri: Format()
@Serializable @SerialName("hyperLink") class HyperLink(val showText: String?, val linkUri: String): Format()
@@ -4384,6 +4385,7 @@ sealed class Format {
is StrikeThrough -> SpanStyle(textDecoration = TextDecoration.LineThrough)
is Snippet -> SpanStyle(fontFamily = FontFamily.Monospace)
is Secret -> SpanStyle(color = Color.Transparent, background = SecretColor)
is Small -> SpanStyle(fontSize = MaterialTheme.typography.body2.fontSize, color = MaterialTheme.colors.secondary)
is Colored -> SpanStyle(color = this.color.uiColor)
is Uri -> linkStyle
is HyperLink -> linkStyle
@@ -153,6 +153,7 @@ fun MarkdownText (
is Format.Italic -> withStyle(ft.format.style) { append(ft.text) }
is Format.StrikeThrough -> withStyle(ft.format.style) { append(ft.text) }
is Format.Snippet -> withStyle(ft.format.style) { append(ft.text) }
is Format.Small -> withStyle(ft.format.style) { append(ft.text) }
is Format.Colored -> withStyle(ft.format.style) { append(ft.text) }
is Format.Secret -> {
val ftStyle = ft.format.style
+3
View File
@@ -1987,6 +1987,9 @@ Snippet:
Secret:
- type: "secret"
Small:
- type: "small"
Colored:
- type: "colored"
- color: [Color](#color)
@@ -2281,6 +2281,7 @@ export type Format =
| Format.StrikeThrough
| Format.Snippet
| Format.Secret
| Format.Small
| Format.Colored
| Format.Uri
| Format.HyperLink
@@ -2297,6 +2298,7 @@ export namespace Format {
| "strikeThrough"
| "snippet"
| "secret"
| "small"
| "colored"
| "uri"
| "hyperLink"
@@ -2330,6 +2332,10 @@ export namespace Format {
type: "secret"
}
export interface Small extends Interface {
type: "small"
}
export interface Colored extends Interface {
type: "colored"
color: Color
+66
View File
@@ -0,0 +1,66 @@
# Small Text Markdown
Add `!- text!` syntax for small gray text — legal disclaimers, secondary commentary, LLM reasoning, etc.
## Syntax
`!- text!` — renders as small gray text. Uses the `!` style prefix family, `-` for "reduced."
On old clients: `!- fine print!` shows as-is (old `coloredP` fails on `-`, falls to `wordP`). Readable.
## Changes
### Haskell — `src/Simplex/Chat/Markdown.hs`
1. **`Format`**: add `Small` constructor (no fields).
2. **`coloredP` parser**: before trying `colorP`, check for `-` followed by space. If matched, produce `Small`. Otherwise fall through to existing color parsing.
3. **`markdownText`**: add `Small` case, reconstruct as `!- text!`.
4. **JSON serialization**: TH-derived `ToJSON`/`FromJSON` via existing `sumTypeJSON fstToLower`. Produces `{"small": {}}`. Old Haskell `FromJSON Format` falls to `Unknown` via `<|> pure (Unknown v)`.
### Haskell — `src/Simplex/Chat/Styled.hs`
5. **`sgr`**: add `Small` case — map to `FaintIntensity` for terminal rendering.
### Haskell — `tests/MarkdownTests.hs`
6. Tests for:
- `!- text!` parses as `Small`
- `!- text!` with leading/trailing spaces in content → no format (same rule as other formats)
- Existing color syntax unchanged
- `markdownText` round-trip
### iOS — `apps/ios/SimpleXChat/ChatTypes.swift`
7. **`Format`** enum: add `case small`.
### iOS — `apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift`
8. **`messageText`**: render `Small` with smaller `UIFont` point size + gray color.
### Android — `apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt`
9. **`Format`**: add `@Serializable @SerialName("small") class Small: Format()`.
10. **`Format.style`**: `SpanStyle` with smaller font size + gray color.
### Android — `apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt`
11. **`MarkdownText`**: add `is Format.Small` case — same pattern as `Bold`/`Italic` (apply style, append text).
## Backward Compatibility
### Local (old app receiving message with new syntax)
- Old app's bundled Haskell parses raw message text. Old `coloredP` doesn't know `-`, fails, falls to `wordP`. Text shows as `!- fine print!` — plain text with delimiters.
### Remote desktop (old desktop, new mobile)
- New mobile Haskell parses `!- text!` as `Small`, serializes to JSON `{"small": {}}`.
- Old desktop Haskell re-parses JSON via `J.parseJSON` (`Remote/Protocol.hs:184`). Old `FromJSON Format` doesn't know `"small"``<|> pure (Unknown v)`.
- `Unknown` re-serializes to `{"type": "unknown", "json": ...}` → Kotlin `Format.Unknown` (`ignoreUnknownKeys` drops extra fields). Text renders without formatting.
## Order of Implementation
1. Haskell types + parser + tests
2. iOS types + rendering
3. Android types + rendering
+8 -6
View File
@@ -53,6 +53,7 @@ data Format
| StrikeThrough
| Snippet
| Secret
| Small
| Colored {color :: FormatColor}
| Uri
-- showText is Nothing for the usual Uri without text
@@ -202,7 +203,7 @@ markdownP = mconcat <$> A.many' fragmentP
'~' -> formattedP '~' StrikeThrough
'`' -> formattedP '`' Snippet
'#' -> A.char '#' *> secretP
'!' -> coloredP <|> wordP
'!' -> styledP <|> wordP
'@' -> mentionP <|> wordP
'/' -> commandP <|> wordP
'[' -> sowLinkP <|> wordP
@@ -228,13 +229,13 @@ markdownP = mconcat <$> A.many' fragmentP
| otherwise = markdown Secret $ T.init ss
where
ss = b <> s <> a
coloredP :: Parser Markdown
coloredP = do
clr <- A.char '!' *> colorP <* A.space
styledP :: Parser Markdown
styledP = do
f <- A.char '!' *> ((A.char '-' $> Small) <|> (colored <$> colorP)) <* A.space
s <- ((<>) <$> A.takeWhile1 (\c -> c /= ' ' && c /= '!') <*> A.takeTill (== '!')) <* A.char '!'
if T.null s || T.last s == ' '
then fail "not colored"
else pure $ markdown (colored clr) s
then fail "not styled"
else pure $ markdown f s
mentionP = prefixedStringP '@' displayNameTextP_ Mention
commandP = prefixedStringP '/' commandTextP Command
prefixedStringP pfx parser format = do
@@ -441,6 +442,7 @@ markdownText (FormattedText f_ t) = case f_ of
StrikeThrough -> around '~'
Snippet -> around '`'
Secret -> around '#'
Small -> "!- " <> t <> "!"
Colored (FormatColor c) -> color c
Uri -> t
HyperLink {} -> t
+1
View File
@@ -75,6 +75,7 @@ sgr = \case
StrikeThrough -> [SetSwapForegroundBackground True]
Colored (FormatColor c) -> [SetColor Foreground Vivid c]
Secret -> [SetColor Foreground Dull Black, SetColor Background Dull Black]
Small -> [SetConsoleIntensity FaintIntensity]
_ -> []
unStyle :: StyledString -> String
+30
View File
@@ -20,6 +20,7 @@ markdownTests :: Spec
markdownTests = do
textFormat
secretText
textSmall
textColor
textWithUri
textWithHyperlink
@@ -131,6 +132,35 @@ secretText = describe "secret text" do
"snippet: `this is #secret_text#`"
<==> "snippet: " <> markdown Snippet "this is #secret_text#"
small :: Text -> Markdown
small = markdown Small
textSmall :: Spec
textSmall = describe "text small" do
it "correct markdown" do
"this is !- small! text"
<==> "this is " <> small "small" <> " text"
"!- small! text"
<==> small "small" <> " text"
"this is !- small!"
<==> "this is " <> small "small"
" !- small! text"
<==> " " <> small "small" <> " text"
"this is !- small! "
<==> "this is " <> small "small" <> " "
it "ignored as markdown" do
"this is !- unformatted ! text"
<==> "this is !- unformatted ! text"
"this is !- unformatted! text"
<==> "this is !- unformatted! text"
"this is!- unformatted! text"
<==> "this is!- unformatted! text"
"this is !- unformatted text"
<==> "this is !- unformatted text"
it "ignored internal markdown" do
"this is !- long *small* (not bold)! text"
<==> "this is " <> small "long *small* (not bold)" <> " text"
red :: Text -> Markdown
red = markdown (colored Red)
+9
View File
@@ -94,6 +94,15 @@ active_directory: true
color: magenta;
}
#directory .entry .small-text {
font-size: 0.8em;
color: #888;
}
.dark #directory .entry .small-text {
color: #999;
}
.dark #directory .entry .green {
color: #4DDA67;
}
+3
View File
@@ -433,6 +433,9 @@ function renderMarkdown(fts) {
case 'secret':
html += `<span class="secret">${escapeHtml(text)}</span>`;
break;
case 'small':
html += `<span class="small-text">${escapeHtml(text)}</span>`;
break;
case 'colored':
html += `<span class="${format.color}">${escapeHtml(text)}</span>`;
break;