ios+mac: an open group with only you is a group, not a second "Notes" (#1501)
createChat derived the thread kind from the member list first (`others.isEmpty ? "self" : …`), so the caller's explicit group intent lost whenever nobody else was picked. Both native pickers deliberately allow that for an OPEN group — others find it in Discover — so "create an open group" with an empty selection posted kind:"self": the name blob and the open_key were both dropped (openIKB64 is gated on kind == "group"), and the result showed up in the sidebar as a second "Notes" that appeared nowhere in Discover. Group intent now wins outright, via a pure ThreadStore.threadKind() so the rule is unit-testable. Matches the web, whose createThread sends group ? "group" : "dm" and never derives "self" from an empty list.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
3 files changed,
+41
−10
ios/LChatCore/Package.resolved+0 −9ios/LChatCore/Sources/LChatCore/ThreadStore.swift+13 −1ios/LChatCore/Tests/LChatCoreTests/ThreadKindTests.swift+28 −0
diff --git a/ios/LChatCore/Package.resolved b/ios/LChatCore/Package.resolved| index fe25d1c..d82f91f 100644 |
| --- a/ios/LChatCore/Package.resolved |
| +++ b/ios/LChatCore/Package.resolved |
| @@ -1,14 +1,5 @@ |
| { |
| "pins" : [ |
| - { |
| - "identity" : "sparkle", |
| - "kind" : "remoteSourceControl", |
| - "location" : "https://github.com/sparkle-project/Sparkle", |
| - "state" : { |
| - "revision" : "b6496a74a087257ef5e6da1c5b29a447a60f5bd7", |
| - "version" : "2.9.4" |
| - } |
| - }, |
| { |
| "identity" : "swift-cmark", |
| "kind" : "remoteSourceControl", |
diff --git a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift| index 6b3f45a..c4cfd0d 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift |
| @@ -1481,7 +1481,7 @@ public final class ThreadStore: ObservableObject { |
| return nil |
| } |
| - let kind = others.isEmpty ? "self" : (group || others.count > 1 ? "group" : "dm") |
| + let kind = ThreadStore.threadKind(others: others, group: group) |
| let name = groupName?.trimmingCharacters(in: .whitespaces) |
| let memberIDs = ids |
| let memberPubs = pubs |
| @@ -1901,6 +1901,18 @@ public final class ThreadStore: ObservableObject { |
| // "A" / "A & B" / "A, B & C" / "A, B & N more" — keep in lockstep with |
| // format.js's autoGroupTitle so a chat is called the same thing everywhere. |
| + /// Which `kind` a new chat is created as. The caller's `group` intent |
| + /// wins outright — an OPEN group legitimately starts with nobody but its |
| + /// creator (others find it in Discover), and before this an empty member |
| + /// list made it a kind:"self" thread, i.e. a second "Notes" (the group's |
| + /// name and open_key silently dropped). Mirrors the web's |
| + /// createThread(otherIds, {group}), which never derives "self" here. |
| + nonisolated public static func threadKind(others: [String], group: Bool) -> String { |
| + if group { return "group" } |
| + if others.isEmpty { return "self" } |
| + return others.count > 1 ? "group" : "dm" |
| + } |
| + |
| nonisolated public static func autoGroupTitle(_ names: [String]) -> String { |
| switch names.count { |
| case 0: return "Group" |
diff --git a/ios/LChatCore/Tests/LChatCoreTests/ThreadKindTests.swift b/ios/LChatCore/Tests/LChatCoreTests/ThreadKindTests.swift| new file mode 100644 |
| index 0000000..167b923 |
| --- /dev/null |
| +++ b/ios/LChatCore/Tests/LChatCoreTests/ThreadKindTests.swift |
| @@ -0,0 +1,28 @@ |
| +import XCTest |
| +@testable import LChatCore |
| + |
| +// The kind a new chat is created as (ThreadStore.createChat). The regression |
| +// this pins: an OPEN group with nobody but its creator — legitimate, since |
| +// others find it in Discover — used to fall through to kind:"self" and appear |
| +// as a second "Notes", its name and open_key dropped on the floor. Mirrors the |
| +// web's createThread(otherIds, {group}) in newchat.js, which sends |
| +// group ? "group" : "dm" and never derives "self" from an empty list. |
| +final class ThreadKindTests: XCTestCase { |
| + func testGroupIntentWinsOverAnEmptyMemberList() { |
| + XCTAssertEqual(ThreadStore.threadKind(others: [], group: true), "group") |
| + } |
| + |
| + func testNoOthersAndNoGroupIntentIsASelfThread() { |
| + XCTAssertEqual(ThreadStore.threadKind(others: [], group: false), "self") |
| + } |
| + |
| + func testOnePersonIsADMUnlessTheGroupFlowWasUsed() { |
| + XCTAssertEqual(ThreadStore.threadKind(others: ["u2"], group: false), "dm") |
| + XCTAssertEqual(ThreadStore.threadKind(others: ["u2"], group: true), "group") |
| + } |
| + |
| + func testTwoOrMoreOthersAreAlwaysAGroup() { |
| + XCTAssertEqual(ThreadStore.threadKind(others: ["u2", "u3"], group: false), "group") |
| + XCTAssertEqual(ThreadStore.threadKind(others: ["u2", "u3"], group: true), "group") |
| + } |
| +} |