diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift index 852c8bbbac..77bd41c5b8 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift @@ -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) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 314f1c072c..9265138c53 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -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 = ""; }; 64C829982D54AEED006B9E89 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 64C829992D54AEEE006B9E89 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 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 = ""; }; - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.9-3esYZFBUokREq84LvcOgzJ.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.0.9-3esYZFBUokREq84LvcOgzJ.a"; sourceTree = ""; }; + 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 = ""; }; + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.9-IckAKQLBKZZ3c4EBa1qhzo.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.0.9-IckAKQLBKZZ3c4EBa1qhzo.a"; sourceTree = ""; }; 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressView.swift; sourceTree = ""; }; 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressLearnMore.swift; sourceTree = ""; }; @@ -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 = ""; diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index b2a9611593..d95e5233c1 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -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) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 668e18cf6c..3d6b227df7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -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 diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt index 60595fc255..3984e5bc40 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt @@ -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 diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index 5dcfe81831..abb26bf266 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -1987,6 +1987,9 @@ Snippet: Secret: - type: "secret" +Small: +- type: "small" + Colored: - type: "colored" - color: [Color](#color) diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index 65ce90f647..0e9a527f1a 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -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 diff --git a/plans/2026-03-21-text-size-markdown.md b/plans/2026-03-21-text-size-markdown.md new file mode 100644 index 0000000000..15389a2c1b --- /dev/null +++ b/plans/2026-03-21-text-size-markdown.md @@ -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 diff --git a/src/Simplex/Chat/Markdown.hs b/src/Simplex/Chat/Markdown.hs index 3287b263d4..86ba441a57 100644 --- a/src/Simplex/Chat/Markdown.hs +++ b/src/Simplex/Chat/Markdown.hs @@ -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 diff --git a/src/Simplex/Chat/Styled.hs b/src/Simplex/Chat/Styled.hs index 27c7936009..2dd1faa391 100644 --- a/src/Simplex/Chat/Styled.hs +++ b/src/Simplex/Chat/Styled.hs @@ -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 diff --git a/tests/MarkdownTests.hs b/tests/MarkdownTests.hs index ecf923901f..3683b536dd 100644 --- a/tests/MarkdownTests.hs +++ b/tests/MarkdownTests.hs @@ -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) diff --git a/website/src/directory.html b/website/src/directory.html index 0235583ece..b20e279d82 100644 --- a/website/src/directory.html +++ b/website/src/directory.html @@ -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; } diff --git a/website/src/js/directory.js b/website/src/js/directory.js index 6ccdf23033..3000ed0877 100644 --- a/website/src/js/directory.js +++ b/website/src/js/directory.js @@ -433,6 +433,9 @@ function renderMarkdown(fts) { case 'secret': html += `${escapeHtml(text)}`; break; + case 'small': + html += `${escapeHtml(text)}`; + break; case 'colored': html += `${escapeHtml(text)}`; break;