Compare commits

...

5 Commits

Author SHA1 Message Date
Taksh
e0012711de fix: insert quote string without reformatting the catalog
Replace the full Localizable.xcstrings rewrite from the previous commit
with a surgical content.message.quote entry so the diff stays reviewable.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 17:51:32 +03:00
Taksh
f8239de844 Address Jack review on quote-in-composer (#1570).
Add content.message.quote to the 30-locale catalog; use a plain sender
header (no @) so quotes do not re-ping; drop the shared draft when the
composer target changes; and only skip the newline insert after a real
trailing newline (not a trailing space).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 17:50:36 +03:00
Taksh
6a1475e98a test: cover message quote formatting and draft append behavior
Pinned multiline quotes, system-sender omission, and non-destructive append.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 11:44:54 +03:00
Taksh
0a4a5a5cc5 feat: add quote-in-composer beside copy on message context menu
Keeps an existing draft and inserts a sender-attributed quote block
so replies can reference nearby chat without retyping.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 11:44:54 +03:00
Taksh
7bea37c92f feat: extract MessageClipboard for plaintext and quote formatting
Centralize pasteboard writes and composer quote blocks so message
actions stay testable without SwiftUI.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 11:44:54 +03:00
5 changed files with 316 additions and 7 deletions

View File

@ -39953,6 +39953,192 @@
}
}
},
"content.message.quote" : {
"extractionState" : "manual",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "اقتباس في المسودة"
}
},
"bn" : {
"stringUnit" : {
"state" : "translated",
"value" : "কম্পোজারে উদ্ধৃতি"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "in entwurf zitieren"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "quote in composer"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "citar en el compositor"
}
},
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "نقل‌قول در پیش‌نویس"
}
},
"fil" : {
"stringUnit" : {
"state" : "translated",
"value" : "quote sa composer"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "citer dans le compositeur"
}
},
"he" : {
"stringUnit" : {
"state" : "translated",
"value" : "ציטוט בטיוטה"
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "कंपोज़र में उद्धृत करें"
}
},
"id" : {
"stringUnit" : {
"state" : "translated",
"value" : "kutip di composer"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "cita nel compositore"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "入力欄に引用"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "작성창에 인용"
}
},
"ms" : {
"stringUnit" : {
"state" : "translated",
"value" : "petik dalam composer"
}
},
"ne" : {
"stringUnit" : {
"state" : "translated",
"value" : "कम्पोजरमा उद्धरण"
}
},
"nl" : {
"stringUnit" : {
"state" : "translated",
"value" : "citeren in opsteller"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "cytuj w polu wpisu"
}
},
"pt" : {
"stringUnit" : {
"state" : "translated",
"value" : "citar no compositor"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "citar no compositor"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "цитировать в поле ввода"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "citera i skrivfältet"
}
},
"ta" : {
"stringUnit" : {
"state" : "translated",
"value" : "எழுதும் இடத்தில் மேற்கோள்"
}
},
"th" : {
"stringUnit" : {
"state" : "translated",
"value" : "อ้างในช่องพิมพ์"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
"value" : "yazma alanına alıntıla"
}
},
"uk" : {
"stringUnit" : {
"state" : "translated",
"value" : "цитувати у полі вводу"
}
},
"ur" : {
"stringUnit" : {
"state" : "translated",
"value" : "کمپوزر میں اقتباس"
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "trích dẫn vào ô soạn"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "引用到输入框"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "引用到輸入框"
}
}
}
},
"content.message.show_less" : {
"extractionState" : "manual",
"localizations" : {

View File

@ -0,0 +1,62 @@
//
// MessageClipboard.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
#if os(iOS)
import UIKit
#elseif os(macOS)
import AppKit
#endif
/// Clipboard helpers for message actions. Kept out of the view so unit tests
/// can assert quote formatting without standing up SwiftUI.
enum MessageClipboard {
/// Plain message body for the pasteboard.
static func copyPlaintext(_ content: String) {
#if os(iOS)
UIPasteboard.general.string = content
#elseif os(macOS)
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(content, forType: .string)
#endif
}
/// Builds a composer-ready quote block (`> line` per line, trailing blank).
/// Sender header is plain text (`> alice:`), not `@alice`, so quoting does
/// not re-fire mention notifications for the quoted person or for names
/// inside the quoted body (those stay behind `> ` and are not live tokens).
static func quoteForComposer(_ content: String, sender: String?) -> String {
let body = content.trimmingCharacters(in: .whitespacesAndNewlines)
guard !body.isEmpty else { return "" }
let header: String
if let sender, !sender.isEmpty, sender != "system" {
header = "> \(sender):\n"
} else {
header = ""
}
let quoted = body
.split(separator: "\n", omittingEmptySubsequences: false)
.map { "> \($0)" }
.joined(separator: "\n")
return header + quoted + "\n\n"
}
/// Appends a quote to an existing composer draft without wiping it.
/// Only a trailing newline skips inserting another separator a trailing
/// space must still get a newline so the quote marker starts its own line.
static func appendQuote(to draft: String, content: String, sender: String?) -> String {
let quote = quoteForComposer(content, sender: sender)
guard !quote.isEmpty else { return draft }
if draft.isEmpty { return quote }
if draft.hasSuffix("\n") {
return draft + quote
}
return draft + "\n" + quote
}
}

View File

@ -251,12 +251,17 @@ struct ContentView: View {
.frame(minWidth: 600, minHeight: 400)
#endif
.onChange(of: selectedPrivatePeerID) { newValue in
// Shared composer draft must not travel across conversation
// targets (quoting a DM then opening public would otherwise
// one-tap-broadcast private content).
messageText = ""
if newValue != nil {
showSidebar = true
}
sharedContentImportModel.updateDestination(sharedContentDestination)
}
.onChange(of: locationChannelsModel.selectedChannel) { _ in
messageText = ""
sharedContentImportModel.updateDestination(sharedContentDestination)
}
.sheet(

View File

@ -143,13 +143,23 @@ struct MessageListView: View {
}
}
Button("content.message.copy") {
#if os(iOS)
UIPasteboard.general.string = message.content
#else
let pb = NSPasteboard.general
pb.clearContents()
pb.setString(message.content, forType: .string)
#endif
MessageClipboard.copyPlaintext(message.content)
}
if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
message.sender != "system" {
Button {
messageText = MessageClipboard.appendQuote(
to: messageText,
content: message.content,
sender: message.sender
)
} label: {
Text(String(
localized: "content.message.quote",
defaultValue: "quote in composer",
comment: "Context menu action that inserts a quoted copy of the message into the composer draft"
))
}
}
if isResendableFailedMessage(message) {
Button("content.actions.resend") {

View File

@ -0,0 +1,46 @@
import Foundation
import Testing
@testable import bitchat
struct MessageClipboardTests {
@Test func quoteWrapsEachLineAndAddsSender() {
let quoted = MessageClipboard.quoteForComposer("hello\nworld", sender: "alice")
#expect(quoted == "> alice:\n> hello\n> world\n\n")
}
@Test func quoteSkipsSystemSenderHeader() {
let quoted = MessageClipboard.quoteForComposer("joined", sender: "system")
#expect(quoted == "> joined\n\n")
}
@Test func emptyContentYieldsEmptyQuote() {
#expect(MessageClipboard.quoteForComposer(" ", sender: "alice").isEmpty)
}
@Test func appendQuotePreservesExistingDraft() {
let result = MessageClipboard.appendQuote(
to: "already typing",
content: "prior message",
sender: "bob"
)
#expect(result == "already typing\n> bob:\n> prior message\n\n")
}
@Test func appendQuoteOntoEmptyDraft() {
let result = MessageClipboard.appendQuote(
to: "",
content: "solo",
sender: "carol"
)
#expect(result == "> carol:\n> solo\n\n")
}
@Test func appendQuoteAfterTrailingSpaceStartsNewLine() {
let result = MessageClipboard.appendQuote(
to: "draft with space ",
content: "quoted",
sender: "dana"
)
#expect(result == "draft with space \n> dana:\n> quoted\n\n")
}
}