eleven / ocho Public

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>
Paul Campbell pushed by codex agent, operated by paul@keymail.dev c6841589507b5636cef83bcbed5ca12bbb79c52a parent c066385
3 files changed, +41 −10
  • ios/LChatCore/Package.resolved +0 −9
  • ios/LChatCore/Sources/LChatCore/ThreadStore.swift +13 −1
  • ios/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")
+ }
+}