diff --git a/.github/workflows/deploy-canary.yml b/.github/workflows/deploy-canary.yml index 26fd419ed8..90e7bf6aa2 100644 --- a/.github/workflows/deploy-canary.yml +++ b/.github/workflows/deploy-canary.yml @@ -103,6 +103,7 @@ jobs: - uses: actions/checkout@v3 with: ref: ${{ env.tag }} + ssh-key: ${{ secrets.DEPLOY_KEY }} - uses: actions/download-artifact@v3 with: name: "ui-dist-new" diff --git a/apps/tlon-mobile/android/app/build.gradle b/apps/tlon-mobile/android/app/build.gradle index 656bcf5376..2a9641cb1c 100644 --- a/apps/tlon-mobile/android/app/build.gradle +++ b/apps/tlon-mobile/android/app/build.gradle @@ -88,7 +88,7 @@ android { targetSdkVersion rootProject.ext.targetSdkVersion compileSdk rootProject.ext.compileSdkVersion versionCode 108 - versionName "5.0.0" + versionName "5.0.2" buildConfigField("boolean", "REACT_NATIVE_UNSTABLE_USE_RUNTIME_SCHEDULER_ALWAYS", (findProperty("reactNative.unstable_useRuntimeSchedulerAlways") ?: true).toString()) } diff --git a/apps/tlon-mobile/ios/Landscape.xcodeproj/project.pbxproj b/apps/tlon-mobile/ios/Landscape.xcodeproj/project.pbxproj index b97ea9f88a..1a5c57b3e9 100644 --- a/apps/tlon-mobile/ios/Landscape.xcodeproj/project.pbxproj +++ b/apps/tlon-mobile/ios/Landscape.xcodeproj/project.pbxproj @@ -86,6 +86,11 @@ 638753092CC7036C003942F5 /* ShortcutsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 638753072CC7036C003942F5 /* ShortcutsManager.swift */; }; 63A5A04E2CD05CB900928EED /* ChannelEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63A5A04D2CD05CB600928EED /* ChannelEntity.swift */; }; 63A5A04F2CD05CB900928EED /* ChannelEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63A5A04D2CD05CB600928EED /* ChannelEntity.swift */; }; + 63CF1DB92D07665B00C0F34E /* ActivityEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63CF1DB82D07665700C0F34E /* ActivityEvent.swift */; }; + 63CF1DBA2D07665B00C0F34E /* ActivityEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63CF1DB82D07665700C0F34E /* ActivityEvent.swift */; }; + 63CF1DBB2D07665B00C0F34E /* ActivityEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63CF1DB82D07665700C0F34E /* ActivityEvent.swift */; }; + 63CF1DBC2D07665B00C0F34E /* ActivityEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63CF1DB82D07665700C0F34E /* ActivityEvent.swift */; }; + 63CF1DBD2D07665B00C0F34E /* ActivityEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63CF1DB82D07665700C0F34E /* ActivityEvent.swift */; }; 63E27E0B2C5AF26C008ACB45 /* Alamofire+sessionWithSharedCookieStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63E27E0A2C5AF26C008ACB45 /* Alamofire+sessionWithSharedCookieStorage.swift */; }; 63E27E0C2C5AF26C008ACB45 /* Alamofire+sessionWithSharedCookieStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63E27E0A2C5AF26C008ACB45 /* Alamofire+sessionWithSharedCookieStorage.swift */; }; 63E27E0D2C5AF26C008ACB45 /* Alamofire+sessionWithSharedCookieStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63E27E0A2C5AF26C008ACB45 /* Alamofire+sessionWithSharedCookieStorage.swift */; }; @@ -254,6 +259,7 @@ 6374ACFC2C4ACD7500E637C0 /* Login.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Login.swift; sourceTree = ""; }; 638753072CC7036C003942F5 /* ShortcutsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutsManager.swift; sourceTree = ""; }; 63A5A04D2CD05CB600928EED /* ChannelEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelEntity.swift; sourceTree = ""; }; + 63CF1DB82D07665700C0F34E /* ActivityEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityEvent.swift; sourceTree = ""; }; 63E27E0A2C5AF26C008ACB45 /* Alamofire+sessionWithSharedCookieStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Alamofire+sessionWithSharedCookieStorage.swift"; sourceTree = ""; }; 63E27E122C5AF5B8008ACB45 /* HTTPCookieStorage+forwardChanges.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = "HTTPCookieStorage+forwardChanges.swift"; path = "Landscape/HTTPCookieStorage+forwardChanges.swift"; sourceTree = ""; }; 63EC5CBF2CD049540098C343 /* SQLiteDB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SQLiteDB.swift; sourceTree = ""; }; @@ -449,6 +455,7 @@ 63E7040D2C540FD6006CF214 /* Models */ = { isa = PBXGroup; children = ( + 63CF1DB82D07665700C0F34E /* ActivityEvent.swift */, 709FC0EC2AC1E25D00B0644D /* Club.swift */, 700B635C2A71DFE90017F40F /* Contact.swift */, 70D3866A2A60A38400AFB46E /* Group.swift */, @@ -1217,6 +1224,7 @@ 70D3865B2A609BFC00AFB46E /* PocketAPI.swift in Sources */, 70D386582A609BFC00AFB46E /* PushNotificationManager.swift in Sources */, 63E27E0B2C5AF26C008ACB45 /* Alamofire+sessionWithSharedCookieStorage.swift in Sources */, + 63CF1DBC2D07665B00C0F34E /* ActivityEvent.swift in Sources */, 700B635D2A71DFE90017F40F /* Contact.swift in Sources */, 70EAEAB92A57CE2A00FE96E4 /* UrbitModule.m in Sources */, 70D386672A60A37000AFB46E /* INPerson+Extension.swift in Sources */, @@ -1258,6 +1266,7 @@ 630DE0CB2C51A8780053603B /* Error+isAFTimeout.swift in Sources */, 630DE0CC2C51A8780053603B /* PushNotificationManager.swift in Sources */, 630DE0CD2C51A8780053603B /* Yarn.swift in Sources */, + 63CF1DBA2D07665B00C0F34E /* ActivityEvent.swift in Sources */, 630DE0CE2C51A8780053603B /* GroupChannelStore.swift in Sources */, 63E27E0E2C5AF26C008ACB45 /* Alamofire+sessionWithSharedCookieStorage.swift in Sources */, 630DE0CF2C51A8780053603B /* PocketUserAPI.swift in Sources */, @@ -1290,6 +1299,7 @@ 630DE0B72C51A0F80053603B /* Error+isAFTimeout.swift in Sources */, 632793B62C4ADE9800F942B1 /* PushNotificationManager.swift in Sources */, 632793C22C4AE4FA00F942B1 /* Yarn.swift in Sources */, + 63CF1DBD2D07665B00C0F34E /* ActivityEvent.swift in Sources */, 632793C92C4AE7C900F942B1 /* GroupChannelStore.swift in Sources */, 63E27E0D2C5AF26C008ACB45 /* Alamofire+sessionWithSharedCookieStorage.swift in Sources */, 632793C62C4AE56F00F942B1 /* PocketUserAPI.swift in Sources */, @@ -1324,6 +1334,7 @@ 70DBBFF02B7C60B50021EA96 /* PushNotificationManager.swift in Sources */, 63E27E0C2C5AF26C008ACB45 /* Alamofire+sessionWithSharedCookieStorage.swift in Sources */, 70DBBFF12B7C60B50021EA96 /* Contact.swift in Sources */, + 63CF1DB92D07665B00C0F34E /* ActivityEvent.swift in Sources */, 70DBBFF22B7C60B50021EA96 /* UrbitModule.m in Sources */, 70DBBFF32B7C60B50021EA96 /* INPerson+Extension.swift in Sources */, 70DBBFF42B7C60B50021EA96 /* Error+logWithDomain.swift in Sources */, @@ -1365,6 +1376,7 @@ 70F99A9B2B2D336500D77256 /* INPerson+Extension.swift in Sources */, 70F99AAB2B2D337600D77256 /* UserDefaultsStore.swift in Sources */, 70F99A982B2D2B6E00D77256 /* YarnTests.swift in Sources */, + 63CF1DBB2D07665B00C0F34E /* ActivityEvent.swift in Sources */, 632793C02C4AE4B500F942B1 /* Error+isAFTimeout.swift in Sources */, 70F99AA92B2D337600D77256 /* GroupChannelStore.swift in Sources */, 70F99A9F2B2D336800D77256 /* Group.swift in Sources */, @@ -1427,7 +1439,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 5.0.0; + MARKETING_VERSION = 5.0.2; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -1465,7 +1477,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 5.0.0; + MARKETING_VERSION = 5.0.2; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -1689,7 +1701,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 5.0.0; + MARKETING_VERSION = 5.0.2; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -1732,7 +1744,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 5.0.0; + MARKETING_VERSION = 5.0.2; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", diff --git a/apps/tlon-mobile/ios/Notifications/NotificationService.swift b/apps/tlon-mobile/ios/Notifications/NotificationService.swift index f611b8c619..ae32c010d6 100644 --- a/apps/tlon-mobile/ios/Notifications/NotificationService.swift +++ b/apps/tlon-mobile/ios/Notifications/NotificationService.swift @@ -9,41 +9,40 @@ class NotificationService: UNNotificationServiceExtension { self.contentHandler = contentHandler bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent) - Task { [weak bestAttemptContent] in - let parsedNotification = await PushNotificationManager.parseNotificationUserInfo(request.content.userInfo) - switch parsedNotification { - case let .notify(yarn): - let (mutatedContent, messageIntent) = await PushNotificationManager.buildNotificationWithIntent( - yarn: yarn, - content: bestAttemptContent ?? UNMutableNotificationContent() - ) - - if let messageIntent { - do { - let interaction = INInteraction(intent: messageIntent, response: nil) - interaction.direction = .incoming - try await interaction.donate() - } catch { - print("Error donating interaction for notification sender details: \(error)") - } - } - - contentHandler(mutatedContent) - return - - case let .failedFetchContents(err): - packErrorOnNotification(err) - contentHandler(bestAttemptContent!) - return - - case .invalid: - fallthrough - - case .dismiss: - contentHandler(bestAttemptContent!) - return + Task { [weak bestAttemptContent] in + let parsedNotification = await PushNotificationManager.parseNotificationUserInfo(request.content.userInfo) + switch parsedNotification { + case let .yarn(yarn, activityEvent): + var notifContent = await handle(yarn) + if let activityEvent { + if let dm = activityEvent.dmPost { + let mutableNotifContent = notifContent.mutableCopy() as! UNMutableNotificationContent + // convert to JSON because `userInfo` needs NSSecureCoding + mutableNotifContent.userInfo["dmPost"] = try! dm.asJson() + notifContent = mutableNotifContent + } else if let post = activityEvent.post { + let mutableNotifContent = notifContent.mutableCopy() as! UNMutableNotificationContent + // convert to JSON because `userInfo` needs NSSecureCoding + mutableNotifContent.userInfo["post"] = try! post.asJson() + notifContent = mutableNotifContent } + } + contentHandler(notifContent) + return + + case let .failedFetchContents(err): + packErrorOnNotification(err) + contentHandler(bestAttemptContent!) + return + + case .invalid: + fallthrough + + case .dismiss: + contentHandler(bestAttemptContent!) + return } + } } /** Appends an error onto the `bestAttemptContent` payload; does *not* attempt to complete the notification request. */ @@ -61,6 +60,24 @@ class NotificationService: UNNotificationServiceExtension { contentHandler(bestAttemptContent) } } + + private func handle(_ renderable: UNNotificationRenderable) async -> UNNotificationContent { + let (mutatedContent, messageIntent) = await renderable.render( + to: bestAttemptContent ?? UNMutableNotificationContent() + ) + + if let messageIntent { + do { + let interaction = INInteraction(intent: messageIntent, response: nil) + interaction.direction = .incoming + try await interaction.donate() + } catch { + print("Error donating interaction for notification sender details: \(error)") + } + } + + return mutatedContent + } } extension Error { @@ -70,3 +87,10 @@ extension Error { print(domain, self) } } + +extension Encodable { + func asJson() throws -> Any { + let data = try JSONEncoder().encode(self) + return try JSONSerialization.jsonObject(with: data, options: []) + } +} diff --git a/apps/tlon-mobile/ios/Shared/Models/ActivityEvent.swift b/apps/tlon-mobile/ios/Shared/Models/ActivityEvent.swift new file mode 100644 index 0000000000..5e36b56bc3 --- /dev/null +++ b/apps/tlon-mobile/ios/Shared/Models/ActivityEvent.swift @@ -0,0 +1,396 @@ +import Foundation + +struct ActivityEvent { + struct ActivityEventResponse: Codable { + let event: ActivityEvent + let time: String + } + + // MARK: - ActvityEvent + struct ActivityEvent: Codable { + let notified: Bool + let dmInvite: Whom? + let groupKick: GroupKick? + let groupAsk: GroupAsk? + let groupJoin: GroupJoin? + let groupRole: GroupRole? + let groupInvite: GroupInvite? + let flagPost: FlagPost? + let flagReply: FlagReply? + let dmPost: DmPost? + let dmReply: DmReply? + let post: Post? + let reply: Reply? + let contact: Contact? + + enum CodingKeys: String, CodingKey { + case notified + case dmInvite = "dm-invite" + case groupKick = "group-kick" + case groupAsk = "group-ask" + case groupJoin = "group-join" + case groupRole = "group-role" + case groupInvite = "group-invite" + case flagPost = "flag-post" + case flagReply = "flag-reply" + case dmPost = "dm-post" + case dmReply = "dm-reply" + case post, reply, contact + } + } + + // MARK: - Contact + struct Contact: Codable { + let update: ContactBookProfile + let who: String + } + + // MARK: - ContactBookProfile + struct ContactBookProfile: Codable { + let avatar: ContactImageField? + let bio: ContactFieldText? + let color: ContactFieldColor? + let cover: ContactImageField? + let groups: ContactFieldGroups? + let nickname, status: ContactFieldText? + } + + // MARK: - ContactImageField + struct ContactImageField: Codable { + let type: AvatarType + let value: String + } + + enum AvatarType: String, Codable { + case look = "look" + } + + // MARK: - ContactFieldText + struct ContactFieldText: Codable { + let type: BioType + let value: String + } + + enum BioType: String, Codable { + case text = "text" + } + + // MARK: - ContactFieldColor + struct ContactFieldColor: Codable { + let type: ColorType + let value: String + } + + enum ColorType: String, Codable { + case tint = "tint" + } + + // MARK: - ContactFieldGroups + struct ContactFieldGroups: Codable { + let type: GroupsType + let value: [Value] + } + + enum GroupsType: String, Codable { + case typeSet = "set" + } + + // MARK: - Value + struct Value: Codable { + let type: ValueType + let value: String + } + + enum ValueType: String, Codable { + case flag = "flag" + } + + // MARK: - Whom + struct Whom: Codable { + let ship, club: String? + } + + // MARK: - DmPost + struct DmPost: Codable { + let content: [Verse] + let key: MessageKey + let mention: Bool + let whom: Whom + } + + // MARK: - Verse + struct Verse: Codable { + let inline: [Inline]? + let block: Block? + } + + // MARK: - Block + struct Block: Codable { + let image: Image? + let listing: Listing? + let header: Header? + let rule: JSONNull? + let code: Code? + let cite: Cite? + } + + // MARK: - Cite + struct Cite: Codable { + let chan: Chan? + let group: String? + let desk: Desk? + let bait: Bait? + } + + // MARK: - Bait + struct Bait: Codable { + let graph, group, baitWhere: String + + enum CodingKeys: String, CodingKey { + case graph, group + case baitWhere = "where" + } + } + + // MARK: - Chan + struct Chan: Codable { + let nest, chanWhere: String + + enum CodingKeys: String, CodingKey { + case nest + case chanWhere = "where" + } + } + + // MARK: - Desk + struct Desk: Codable { + let flag, deskWhere: String + + enum CodingKeys: String, CodingKey { + case flag + case deskWhere = "where" + } + } + + // MARK: - Code + struct Code: Codable { + let code, lang: String + } + + // MARK: - Header + struct Header: Codable { + let content: [Inline] + let tag: HeaderLevel + } + + // MARK: - Task + struct Task: Codable { + let checked: Bool + let content: [Inline] + } + + /// A reference to the accompanying blocks, indexed at 0 + // MARK: - Ship + struct InlineContent: Codable { + let ship: String? + let italics, bold, strike: [Inline]? + let inlineCode, code: String? + let blockquote: [Inline]? + let block: BlockClass? + let tag: String? + let link: Link? + let task: Task? + + enum CodingKeys: String, CodingKey { + case ship, italics, bold, strike + case inlineCode = "inline-code" + case code, blockquote, block, tag, link, task + } + } + + struct InlineLinebreak: Codable { + let linebreak: JSONNull + + enum CodingKeys: String, CodingKey { + case linebreak = "break" + } + } + + enum Inline: Codable { + case content(InlineContent) + case linebreak(InlineLinebreak) + case literal(String) + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let x = try? container.decode(String.self) { + self = .literal(x) + return + } + if let x = try? container.decode(InlineLinebreak.self) { + self = .linebreak(x) + return + } + if let x = try? container.decode(InlineContent.self) { + self = .content(x) + return + } + throw DecodingError.typeMismatch(Inline.self, DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Wrong type for Inline")) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .content(let x): + try container.encode(x) + case .linebreak(let x): + try container.encode(x) + case .literal(let x): + try container.encode(x) + } + } + } + + // MARK: - BlockClass + struct BlockClass: Codable { + let index: Double + let text: String + } + + // MARK: - Link + struct Link: Codable { + let content, href: String + } + + enum HeaderLevel: String, Codable { + case h1 = "h1" + case h2 = "h2" + case h3 = "h3" + case h4 = "h4" + case h5 = "h5" + case h6 = "h6" + } + + // MARK: - Image + struct Image: Codable { + let alt: String + let height: Double + let src: String + let width: Double + } + + // MARK: - List + struct List: Codable { + let contents: [Inline] + let items: [Listing] + let type: ListType + } + + // MARK: - Listing + struct Listing: Codable { + let list: List? + let item: [Inline]? + } + + enum ListType: String, Codable { + case ordered = "ordered" + case tasklist = "tasklist" + case unordered = "unordered" + } + + // MARK: - MessageKey + struct MessageKey: Codable { + let id, time: String + } + + // MARK: - DmReply + struct DmReply: Codable { + let content: [Verse] + let key: MessageKey + let mention: Bool + let parent: MessageKey + let whom: Whom + } + + // MARK: - FlagPost + struct FlagPost: Codable { + let channel, group: String + let key: MessageKey + } + + // MARK: - FlagReply + struct FlagReply: Codable { + let channel, group: String + let key, parent: MessageKey + } + + // MARK: - GroupAsk + struct GroupAsk: Codable { + let group, ship: String + } + + // MARK: - GroupInvite + struct GroupInvite: Codable { + let group, ship: String + } + + // MARK: - GroupJoin + struct GroupJoin: Codable { + let group, ship: String + } + + // MARK: - GroupKick + struct GroupKick: Codable { + let group, ship: String + } + + // MARK: - GroupRole + struct GroupRole: Codable { + let group, role, ship: String + } + + // MARK: - Post + struct Post: Codable { + let channel: String + let content: [Verse] + let group: String + let key: MessageKey + let mention: Bool + } + + // MARK: - Reply + struct Reply: Codable { + let channel: String + let content: [Verse] + let group: String + let key: MessageKey + let mention: Bool + let parent: MessageKey + } + + // MARK: - Encode/decode helpers + + class JSONNull: Codable, Hashable { + + public static func == (lhs: JSONNull, rhs: JSONNull) -> Bool { + return true + } + + func hash(into hasher: inout Hasher) {} + + public init() {} + + public required init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if !container.decodeNil() { + throw DecodingError.typeMismatch(JSONNull.self, DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Wrong type for JSONNull")) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encodeNil() + } + } + +} diff --git a/apps/tlon-mobile/ios/Shared/Networking/PocketNotificationsAPI.swift b/apps/tlon-mobile/ios/Shared/Networking/PocketNotificationsAPI.swift index 46176864f7..eee67a2faa 100644 --- a/apps/tlon-mobile/ios/Shared/Networking/PocketNotificationsAPI.swift +++ b/apps/tlon-mobile/ios/Shared/Networking/PocketNotificationsAPI.swift @@ -13,4 +13,8 @@ extension PocketAPI { let yarn: Yarn = try await fetchDecodable("/apps/groups/~/notify/note/\(uid)/hark-yarn", timeoutInterval: 8) return yarn } + + func fetchPushNotificationContents(_ uid: String) async throws -> ActivityEvent.ActivityEventResponse { + try await fetchDecodable("/apps/groups/~/notify/note/\(uid)/activity-event", timeoutInterval: 8) + } } diff --git a/apps/tlon-mobile/ios/Shared/Notifications/PushNotificationManager.swift b/apps/tlon-mobile/ios/Shared/Notifications/PushNotificationManager.swift index 42eeb7a5a7..f528e27dd8 100644 --- a/apps/tlon-mobile/ios/Shared/Notifications/PushNotificationManager.swift +++ b/apps/tlon-mobile/ios/Shared/Notifications/PushNotificationManager.swift @@ -9,63 +9,12 @@ import Foundation import Intents import NotificationCenter -enum NotificationAction: String { - case reply - case markAsRead - case accept - case deny - - var title: String { - switch self { - case .reply: - return "Reply" - case .markAsRead: - return "Mark as Read" - case .accept: - return "Accept" - case .deny: - return "Deny" - } - } - - var icon: UNNotificationActionIcon { - switch self { - case .reply: - return UNNotificationActionIcon(systemImageName: "arrowshape.turn.up.left") - case .markAsRead: - return UNNotificationActionIcon(systemImageName: "envelope.open") - case .accept: - return UNNotificationActionIcon(systemImageName: "checkmark") - case .deny: - return UNNotificationActionIcon(systemImageName: "xmark") - } - } - - var action: UNNotificationAction { - if self == .reply { - return UNTextInputNotificationAction(identifier: rawValue, title: title, icon: icon) - } - - return UNNotificationAction(identifier: rawValue, title: title, icon: icon) - } -} - enum NotificationCategory: String { case message case invitation - var actions: [NotificationAction] { - [] -// switch self { -// case .message: -// return [.reply, .markAsRead] -// case .invitation: -// return [.accept, .deny] -// } - } - var category: UNNotificationCategory { - UNNotificationCategory(identifier: rawValue, actions: actions.map(\.action), intentIdentifiers: []) + UNNotificationCategory(identifier: rawValue, actions: [], intentIdentifiers: []) } } @@ -75,126 +24,9 @@ enum NotificationCategory: String { NotificationCategory.message.category, ]) } - - @objc static func handleBackgroundNotification(_ userInfo: [AnyHashable: Any]) async -> UIBackgroundFetchResult { - let parseResult = await parseNotificationUserInfo(userInfo) - switch parseResult { - case let .notify(yarn): - // Skip if not a valid push notification - guard yarn.isValidNotification else { - print("Skipping notification: \(yarn)") - return .noData - } - - let success = await sendNotification(with: yarn) - return success ? .newData : .failed - - case let .dismiss(uid): - UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [uid]) - return .newData - - case let .failedFetchContents(error): - error.logWithDomain(TlonError.NotificationsFetchYarn) - - if error.isAFTimeout, await sendFallbackNotification() { - return .newData - } - - return .failed - - case .invalid: - return .noData - } - } - - static func sendNotification(with yarn: Yarn) async -> Bool { - let (content, intent) = await buildNotificationWithIntent(yarn: yarn) - - if let intent { - do { - let interaction = INInteraction(intent: intent, response: nil) - interaction.direction = .incoming - try await interaction.donate() - } catch { - print("Error donating interaction for notification sender details: \(error)") - } - } - - let request = UNNotificationRequest(identifier: yarn.id, content: content, trigger: nil) - do { - try await UNUserNotificationCenter.current().add(request) - return true - } catch { - error.logWithDomain(TlonError.NotificationsShowBanner) - return false - } - } - - static func buildNotificationWithIntent( - yarn: Yarn, - content: UNMutableNotificationContent = UNMutableNotificationContent() - ) async -> (UNNotificationContent, INSendMessageIntent?) { - content.interruptionLevel = .active - content.threadIdentifier = yarn.rope.thread - content.title = await yarn.getTitle() - content.body = yarn.body - // content.badge = await withUnsafeContinuation { cnt in - // UNUserNotificationCenter.current().getDeliveredNotifications { notifs in - // cnt.resume(returning: NSNumber(value: notifs.count + 1)) - // } - // } - content.categoryIdentifier = yarn.category.rawValue - content.userInfo = yarn.userInfo - content.sound = UNNotificationSound.default - - if content.categoryIdentifier == NotificationCategory.message.rawValue, - let senderShipName = yarn.senderShipName - { - let sender = await INPerson.from(shipName: senderShipName, withImage: true) - let intent = INSendMessageIntent( - // Create empty recipient for groups because we don't need the OS creating the participant list - recipients: [INPerson.empty], - outgoingMessageType: .outgoingMessageText, - content: yarn.body, - speakableGroupName: INSpeakableString(spokenPhrase: content.title), - conversationIdentifier: content.threadIdentifier, - serviceName: nil, - sender: sender, - attachments: nil - ) - - if intent.speakableGroupName != nil, let image = sender.image { - intent.setImage(image, forParameterNamed: \.speakableGroupName) - } - - do { - let updatedNotifContent = try content.updating(from: intent) - return (updatedNotifContent, intent) - } catch { - print("Error updating content for notification sender details: \(error)") - return (content, nil) - } - } - - return (content, nil) - } - - static func sendFallbackNotification() async -> Bool { - let content = UNMutableNotificationContent() - content.body = "You have received a notification." - content.userInfo = ["wer": "/notifications"] - let request = UNNotificationRequest(identifier: "notification_fallback", content: content, trigger: nil) - do { - try await UNUserNotificationCenter.current().add(request) - return true - } catch { - error.logWithDomain(TlonError.NotificationsShowFallback) - return false - } - } - + enum ParseNotificationResult { - case notify(Yarn) + case yarn(Yarn, ActivityEvent.ActivityEvent?) case dismiss(uid: String) case invalid case failedFetchContents(Error) @@ -210,8 +42,16 @@ enum NotificationCategory: String { switch action { case "notify": do { - let yarn = try await PocketAPI.shared.fetchPushNotificationContents(uid) - return .notify(yarn) + let yarn: Yarn = try await PocketAPI.shared.fetchPushNotificationContents(uid) + + // HACK: Fetch the activity event in addition to the yarn. This + // is largely redundant, but activity event has the full post + // content, which we want to hand off. In the future, activity + // event should be able to fully replace yarn, but it requires + // more work. + let aer: ActivityEvent.ActivityEventResponse = try await PocketAPI.shared.fetchPushNotificationContents(uid) + + return .yarn(yarn, aer.event) } catch { return .failedFetchContents(error) } @@ -224,3 +64,71 @@ enum NotificationCategory: String { } } } + +/** + This type's value can be represented as a user-facing push alert. + */ +protocol UNNotificationRenderable { + /** + Renders the receiver onto the specified notification content. Caller should discard the input + `UNMutableNotificationContent` and use the returned value, since the identity of the notification + content may change. + + ```swift + let (content, msgIntent) = await payload.render(to: mutableContent) + // `mutableContent` should no longer be used; use `content` instead + if let msgIntent { + // donate intent here + } + ``` + */ + func render(to content: UNMutableNotificationContent) async -> (UNNotificationContent, INSendMessageIntent?) +} + +extension Yarn: UNNotificationRenderable { + func render(to content: UNMutableNotificationContent) async -> (UNNotificationContent, INSendMessageIntent?) { + content.interruptionLevel = .active + content.threadIdentifier = self.rope.thread + content.title = await self.getTitle() + content.body = self.body + // content.badge = await withUnsafeContinuation { cnt in + // UNUserNotificationCenter.current().getDeliveredNotifications { notifs in + // cnt.resume(returning: NSNumber(value: notifs.count + 1)) + // } + // } + content.categoryIdentifier = self.category.rawValue + content.userInfo = self.userInfo + content.sound = UNNotificationSound.default + + if content.categoryIdentifier == NotificationCategory.message.rawValue, + let senderShipName = self.senderShipName + { + let sender = await INPerson.from(shipName: senderShipName, withImage: true) + let intent = INSendMessageIntent( + // Create empty recipient for groups because we don't need the OS creating the participant list + recipients: [INPerson.empty], + outgoingMessageType: .outgoingMessageText, + content: self.body, + speakableGroupName: INSpeakableString(spokenPhrase: content.title), + conversationIdentifier: content.threadIdentifier, + serviceName: nil, + sender: sender, + attachments: nil + ) + + if intent.speakableGroupName != nil, let image = sender.image { + intent.setImage(image, forParameterNamed: \.speakableGroupName) + } + + do { + let updatedNotifContent = try content.updating(from: intent) + return (updatedNotifContent, intent) + } catch { + print("Error updating content for notification sender details: \(error)") + return (content, nil) + } + } + + return (content, nil) + } +} diff --git a/apps/tlon-mobile/metro.config.js b/apps/tlon-mobile/metro.config.js index 69bab1a6cf..e5bcf1f180 100644 --- a/apps/tlon-mobile/metro.config.js +++ b/apps/tlon-mobile/metro.config.js @@ -113,6 +113,9 @@ module.exports = mergeConfig(config, { // Enables importing alternative package exports, e.g. `react-tweet/api` unstable_enablePackageExports: true, + // Removes import, which causes issues for zustand + // This is the default setting in newer versions of react-native + unstable_conditionNames: ['require'], }, }); diff --git a/apps/tlon-mobile/package.json b/apps/tlon-mobile/package.json index c1df3d401f..0eea179da6 100644 --- a/apps/tlon-mobile/package.json +++ b/apps/tlon-mobile/package.json @@ -108,7 +108,6 @@ "react-native-safe-area-context": "^4.9.0", "react-native-screens": "~3.29.0", "react-native-sse": "^1.2.1", - "react-native-storage": "^1.0.1", "react-native-svg": "^15.0.0", "react-native-url-polyfill": "^2.0.0", "react-native-webview": "13.6.4", diff --git a/apps/tlon-mobile/src/App.tsx b/apps/tlon-mobile/src/App.tsx index df72b2d9ff..5ee763abdc 100644 --- a/apps/tlon-mobile/src/App.tsx +++ b/apps/tlon-mobile/src/App.tsx @@ -1,4 +1,7 @@ import { IGNORE_COSMOS } from '@tloncorp/app/constants'; +import { loadConstants } from '@tloncorp/app/lib/constants'; + +loadConstants(); module.exports = (global as any).__DEV__ && !IGNORE_COSMOS diff --git a/apps/tlon-mobile/src/components/AuthenticatedApp.tsx b/apps/tlon-mobile/src/components/AuthenticatedApp.tsx index 8c18a82bd4..9fd10cef1f 100644 --- a/apps/tlon-mobile/src/components/AuthenticatedApp.tsx +++ b/apps/tlon-mobile/src/components/AuthenticatedApp.tsx @@ -11,7 +11,7 @@ import { RootStack } from '@tloncorp/app/navigation/RootStack'; import { AppDataProvider } from '@tloncorp/app/provider/AppDataProvider'; import { sync } from '@tloncorp/shared'; import { PortalProvider, ZStack } from '@tloncorp/ui'; -import { useCallback, useEffect } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { AppStateStatus } from 'react-native'; import { useCheckAppUpdated } from '../hooks/analytics'; @@ -19,10 +19,6 @@ import { useDeepLinkListener } from '../hooks/useDeepLinkListener'; import useNotificationListener from '../hooks/useNotificationListener'; function AuthenticatedApp() { - const shipInfo = useShip(); - const { ship, shipUrl } = shipInfo; - const currentUserId = useCurrentUserId(); - const configureClient = useConfigureUrbitClient(); const telemetry = useTelemetry(); useNotificationListener(); useUpdatePresentedNotifications(); @@ -32,11 +28,6 @@ function AuthenticatedApp() { useCheckAppUpdated(); useFindSuggestedContacts(); - useEffect(() => { - configureClient(); - sync.syncStart(); - }, [currentUserId, ship, shipUrl]); - const handleAppStatusChange = useCallback( (status: AppStateStatus) => { if (status === 'active') { @@ -58,15 +49,22 @@ function AuthenticatedApp() { } export default function ConnectedAuthenticatedApp() { + const [clientReady, setClientReady] = useState(false); + const configureClient = useConfigureUrbitClient(); + + useEffect(() => { + configureClient(); + sync.syncStart(); + setClientReady(true); + }, [configureClient]); + return ( {/* This portal provider overrides the root portal provider to ensure that sheets have access to `AppDataContext` */} - - - + {clientReady && } ); } diff --git a/apps/tlon-mobile/src/hooks/useNotificationListener.ts b/apps/tlon-mobile/src/hooks/useNotificationListener.ts index 4b49dc5a57..33e0aac44e 100644 --- a/apps/tlon-mobile/src/hooks/useNotificationListener.ts +++ b/apps/tlon-mobile/src/hooks/useNotificationListener.ts @@ -1,6 +1,7 @@ import crashlytics from '@react-native-firebase/crashlytics'; import type { NavigationProp } from '@react-navigation/native'; import { useNavigation } from '@react-navigation/native'; +import { useAppStatusChange } from '@tloncorp/app/hooks/useAppStatusChange'; import { useFeatureFlag } from '@tloncorp/app/lib/featureFlags'; import { connectNotifications } from '@tloncorp/app/lib/notifications'; import { RootStackParamList } from '@tloncorp/app/navigation/types'; @@ -10,17 +11,22 @@ import { screenNameFromChannelId, } from '@tloncorp/app/navigation/utils'; import * as posthog from '@tloncorp/app/utils/posthog'; -import { syncDms, syncGroups } from '@tloncorp/shared'; +import { createDevLogger, syncDms, syncGroups } from '@tloncorp/shared'; import { markChatRead } from '@tloncorp/shared/api'; import * as api from '@tloncorp/shared/api'; import * as db from '@tloncorp/shared/db'; import * as store from '@tloncorp/shared/store'; +import * as ub from '@tloncorp/shared/urbit'; import { whomIsDm, whomIsMultiDm } from '@tloncorp/shared/urbit'; +import { useIsWindowNarrow } from '@tloncorp/ui'; import { Notification, addNotificationResponseReceivedListener, + getPresentedNotificationsAsync, } from 'expo-notifications'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; + +const logger = createDevLogger('useNotificationListener', false); type RouteStack = { name: keyof RootStackParamList; @@ -35,6 +41,7 @@ interface WerNotificationData extends BaseNotificationData { channelId: string; postInfo: { id: string; authorId: string; isDm: boolean } | null; wer: string; + post?: db.Post; } interface UnrecognizedNotificationData extends BaseNotificationData { type: 'unrecognized'; @@ -65,12 +72,25 @@ function payloadFromNotification( // welcome to my validation library ;) if (payload.wer != null && payload.channelId != null) { + const postInfo = api.getPostInfoFromWer(payload.wer); + const handoffPost: db.Post | undefined = (() => { + const dmPost = payload.dmPost as ub.DmPostEvent['dm-post'] | undefined; + if (dmPost != null) { + return db.postFromDmPostActivityEvent(dmPost); + } + const post = payload.post as ub.PostEvent['post'] | undefined; + if (post != null) { + return db.postFromPostActivityEvent(post); + } + return undefined; + })(); return { ...baseNotificationData, type: 'wer', channelId: payload.channelId, - postInfo: api.getPostInfoFromWer(payload.wer), + postInfo, wer: payload.wer, + post: handoffPost, }; } return { @@ -87,6 +107,8 @@ export default function useNotificationListener() { const [notifToProcess, setNotifToProcess] = useState(null); + const handoffDataFrom = useHandoffNotificationData(); + // Start notifications prompt useEffect(() => { connectNotifications(); @@ -97,6 +119,8 @@ export default function useNotificationListener() { // This only seems to get triggered on iOS. Android handles the tap and other intents in native code. const notificationTapListener = addNotificationResponseReceivedListener( (response) => { + handoffDataFrom([response.notification]); + const data = payloadFromNotification(response.notification); // If the NSE caught an error, it puts it in a list under @@ -142,7 +166,9 @@ export default function useNotificationListener() { // Clean up listeners notificationTapListener.remove(); }; - }, [navigation, isTlonEmployee]); + }, [navigation, isTlonEmployee, handoffDataFrom]); + + const isDesktop = useIsWindowNarrow(); // If notification tapped, push channel on stack useEffect(() => { @@ -156,7 +182,11 @@ export default function useNotificationListener() { const routeStack: RouteStack = [{ name: 'ChatList' }]; if (channel.groupId) { - const mainGroupRoute = await getMainGroupRoute(channel.groupId); + const mainGroupRoute = await getMainGroupRoute( + channel.groupId, + isDesktop + ); + // @ts-expect-error - we know we're on mobile and we can't get a "Home" route routeStack.push(mainGroupRoute); } // Only push the channel if it wasn't already handled by the main group stack @@ -233,3 +263,45 @@ export default function useNotificationListener() { } }, [notifToProcess, navigation, isTlonEmployee, channelSwitcherEnabled]); } + +function useHandoffNotificationData() { + const handoffDataFrom = useCallback(async (notifications: Notification[]) => { + const handoffPosts = notifications.flatMap((notification) => { + const data = payloadFromNotification(notification); + if (data == null || data.type === 'unrecognized' || data.post == null) { + return []; + } + return [data.post]; + }); + + if (handoffPosts.length > 0) { + await db.insertUnconfirmedPosts({ posts: handoffPosts }); + } + }, []); + + // take data from presented notifications + const handoffFromPresentedNotifications = useCallback(async () => { + handoffDataFrom(await getPresentedNotificationsAsync()); + }, [handoffDataFrom]); + + // take data on launch + useEffect(() => { + handoffFromPresentedNotifications().catch((e) => { + logger.error('Failed to slurp handoffs:', e); + }); + }, [handoffFromPresentedNotifications]); + + // take data on each app resume + useAppStatusChange( + useCallback( + async (status) => { + if (status === 'active') { + await handoffFromPresentedNotifications(); + } + }, + [handoffFromPresentedNotifications] + ) + ); + + return handoffDataFrom; +} diff --git a/apps/tlon-mobile/src/screens/Onboarding/CheckOTPScreen.tsx b/apps/tlon-mobile/src/screens/Onboarding/CheckOTPScreen.tsx index 858bf80fb5..779e50cd06 100644 --- a/apps/tlon-mobile/src/screens/Onboarding/CheckOTPScreen.tsx +++ b/apps/tlon-mobile/src/screens/Onboarding/CheckOTPScreen.tsx @@ -2,12 +2,11 @@ import type { NativeStackScreenProps } from '@react-navigation/native-stack'; import { useSignupParams } from '@tloncorp/app/contexts/branch'; import { useShip } from '@tloncorp/app/contexts/ship'; import { HostingError } from '@tloncorp/app/lib/hostingApi'; -import { isEulaAgreed, setEulaAgreed } from '@tloncorp/app/utils/eula'; import { trackOnboardingAction } from '@tloncorp/app/utils/posthog'; import { getShipUrl } from '@tloncorp/app/utils/ship'; import { AnalyticsEvent, createDevLogger } from '@tloncorp/shared'; import { getLandscapeAuthCookie } from '@tloncorp/shared/api'; -import { didSignUp } from '@tloncorp/shared/db'; +import { storage } from '@tloncorp/shared/db'; import { ScreenHeader, TlonText, View, YStack } from '@tloncorp/ui'; import { useCallback, useState } from 'react'; @@ -140,7 +139,7 @@ export const CheckOTPScreen = ({ navigation, route: { params } }: Props) => { accessCode ); if (authCookie) { - if (await isEulaAgreed()) { + if (await storage.eulaAgreed.getValue()) { setShip({ ship: shipId, shipUrl, @@ -148,7 +147,7 @@ export const CheckOTPScreen = ({ navigation, route: { params } }: Props) => { authType: 'hosted', }); - const hasSignedUp = await didSignUp.getValue(); + const hasSignedUp = await storage.didSignUp.getValue(); if (!hasSignedUp) { logger.trackEvent(AnalyticsEvent.LoggedInBeforeSignup); } @@ -194,7 +193,7 @@ export const CheckOTPScreen = ({ navigation, route: { params } }: Props) => { const handleSubmit = useCallback( async (code: string) => { setIsSubmitting(true); - await setEulaAgreed(); + await storage.eulaAgreed.setValue(true); try { if (mode === 'signup') { const user = await handleSignup(code); diff --git a/apps/tlon-mobile/src/screens/Onboarding/PasteInviteLinkScreen.tsx b/apps/tlon-mobile/src/screens/Onboarding/PasteInviteLinkScreen.tsx index 2b2a0856a4..906c24a663 100644 --- a/apps/tlon-mobile/src/screens/Onboarding/PasteInviteLinkScreen.tsx +++ b/apps/tlon-mobile/src/screens/Onboarding/PasteInviteLinkScreen.tsx @@ -61,10 +61,7 @@ export const PasteInviteLinkScreen = ({ navigation }: Props) => { const inviteLinkValue = watch('inviteLink'); useEffect(() => { async function handleInviteLinkChange() { - const extractedLink = extractNormalizedInviteLink( - inviteLinkValue, - BRANCH_DOMAIN - ); + const extractedLink = extractNormalizedInviteLink(inviteLinkValue); setMetadataError(null); if (extractedLink) { try { @@ -103,6 +100,10 @@ export const PasteInviteLinkScreen = ({ navigation }: Props) => { trackOnboardingAction({ actionName: 'Invite Link Added', lure: lureMeta.id, + inviteType: + lureMeta.inviteType && lureMeta.inviteType === 'user' + ? 'personal' + : 'group', }); navigation.reset({ diff --git a/apps/tlon-mobile/src/screens/Onboarding/ReserveShipScreen.tsx b/apps/tlon-mobile/src/screens/Onboarding/ReserveShipScreen.tsx index 527bb3b992..496b8858d0 100644 --- a/apps/tlon-mobile/src/screens/Onboarding/ReserveShipScreen.tsx +++ b/apps/tlon-mobile/src/screens/Onboarding/ReserveShipScreen.tsx @@ -105,7 +105,7 @@ function BootStepDisplay(props: { props.bootPhase <= step.endInclusive; const hasCompleted = props.bootPhase > step.endInclusive; return ( - + {step.description} diff --git a/apps/tlon-mobile/src/screens/Onboarding/ShipLoginScreen.tsx b/apps/tlon-mobile/src/screens/Onboarding/ShipLoginScreen.tsx index f455184486..69524a6bad 100644 --- a/apps/tlon-mobile/src/screens/Onboarding/ShipLoginScreen.tsx +++ b/apps/tlon-mobile/src/screens/Onboarding/ShipLoginScreen.tsx @@ -5,12 +5,11 @@ import { DEFAULT_SHIP_LOGIN_URL, } from '@tloncorp/app/constants'; import { useShip } from '@tloncorp/app/contexts/ship'; -import { setEulaAgreed } from '@tloncorp/app/utils/eula'; import { getShipFromCookie } from '@tloncorp/app/utils/ship'; import { transformShipURL } from '@tloncorp/app/utils/string'; import { AnalyticsEvent, createDevLogger } from '@tloncorp/shared'; import { getLandscapeAuthCookie } from '@tloncorp/shared/api'; -import { didSignUp, finishingSelfHostedLogin } from '@tloncorp/shared/db'; +import { storage } from '@tloncorp/shared/db'; import { Field, KeyboardAvoidingView, @@ -59,7 +58,7 @@ export const ShipLoginScreen = ({ navigation }: Props) => { }); const { setShip } = useShip(); const { setValue: setFinishingSelfHostedLogin } = - finishingSelfHostedLogin.useStorageItem(); + storage.finishingSelfHostedLogin.useStorageItem(); const [codevisible, setCodeVisible] = useState(false); @@ -84,7 +83,7 @@ export const ShipLoginScreen = ({ navigation }: Props) => { const { shipUrl: rawShipUrl, accessCode } = params; setIsSubmitting(true); - setEulaAgreed(); + storage.eulaAgreed.setValue(true); const shipUrl = transformShipURL(rawShipUrl); setFormattedShipUrl(shipUrl); @@ -110,7 +109,7 @@ export const ShipLoginScreen = ({ navigation }: Props) => { authType: 'self', }); - const hasSignedUp = await didSignUp.getValue(); + const hasSignedUp = await storage.didSignUp.getValue(); if (!hasSignedUp) { logger.trackEvent(AnalyticsEvent.LoggedInBeforeSignup); } diff --git a/apps/tlon-mobile/src/screens/Onboarding/TlonLoginLegacy.tsx b/apps/tlon-mobile/src/screens/Onboarding/TlonLoginLegacy.tsx index be8af378a6..8673bc44d2 100644 --- a/apps/tlon-mobile/src/screens/Onboarding/TlonLoginLegacy.tsx +++ b/apps/tlon-mobile/src/screens/Onboarding/TlonLoginLegacy.tsx @@ -11,11 +11,10 @@ import { logInHostingUser, requestPhoneVerify, } from '@tloncorp/app/lib/hostingApi'; -import { isEulaAgreed, setEulaAgreed } from '@tloncorp/app/utils/eula'; import { getShipUrl } from '@tloncorp/app/utils/ship'; import { AnalyticsEvent, createDevLogger } from '@tloncorp/shared'; import { getLandscapeAuthCookie } from '@tloncorp/shared/api'; -import { didSignUp } from '@tloncorp/shared/db'; +import { storage } from '@tloncorp/shared/db'; import { Field, KeyboardAvoidingView, @@ -78,7 +77,7 @@ export const TlonLoginLegacy = ({ navigation }: Props) => { const onSubmit = handleSubmit(async (params) => { setIsSubmitting(true); - await setEulaAgreed(); + await storage.eulaAgreed.setValue(true); try { const user = await logInHostingUser(params); @@ -95,7 +94,7 @@ export const TlonLoginLegacy = ({ navigation }: Props) => { accessCode ); if (authCookie) { - if (await isEulaAgreed()) { + if (await storage.eulaAgreed.getValue()) { setShip({ ship: shipId, shipUrl, @@ -103,7 +102,7 @@ export const TlonLoginLegacy = ({ navigation }: Props) => { authType: 'hosted', }); - const hasSignedUp = await didSignUp.getValue(); + const hasSignedUp = await storage.didSignUp.getValue(); if (!hasSignedUp) { logger.trackEvent(AnalyticsEvent.LoggedInBeforeSignup); } diff --git a/apps/tlon-mobile/src/screens/Onboarding/WelcomeScreen.tsx b/apps/tlon-mobile/src/screens/Onboarding/WelcomeScreen.tsx index 234c83f21c..1f38a28ef5 100644 --- a/apps/tlon-mobile/src/screens/Onboarding/WelcomeScreen.tsx +++ b/apps/tlon-mobile/src/screens/Onboarding/WelcomeScreen.tsx @@ -59,6 +59,10 @@ export const WelcomeScreen = ({ navigation }: Props) => { trackOnboardingAction({ actionName: 'Invite Link Added', lure: lureMeta.id, + inviteType: + lureMeta.inviteType && lureMeta.inviteType === 'user' + ? 'personal' + : 'group', }); } }, [lureMeta]); diff --git a/apps/tlon-mobile/tsconfig.json b/apps/tlon-mobile/tsconfig.json index 5c51ec51ae..079885c4a7 100644 --- a/apps/tlon-mobile/tsconfig.json +++ b/apps/tlon-mobile/tsconfig.json @@ -3,15 +3,12 @@ "include": [ "src/**/*.ts", "src/**/*.tsx", - "cosmos.imports.ts", "tamagui.config.ts", "tamagui.d.ts", "index.js", "svg.d.ts", - "../../cosmos.imports.ts", - "../../packages/app/fixtures/**/*.tsx", - "../../packages/app/fixtures/**/*.ts" ], + "exclude": ["src/App.cosmos.tsx"], "compilerOptions": { "composite": true, "moduleSuffixes": [".native", ".ios", ".android", ""] diff --git a/apps/tlon-web-new/package.json b/apps/tlon-web-new/package.json index ddb37e5325..f07b734490 100644 --- a/apps/tlon-web-new/package.json +++ b/apps/tlon-web-new/package.json @@ -93,6 +93,7 @@ "prosemirror-view": "~1.23.13", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-dropzone": "^14.3.5", "react-error-boundary": "^3.1.4", "react-helmet": "^6.1.0", "react-intersection-observer": "^9.4.0", diff --git a/apps/tlon-web-new/src/app.tsx b/apps/tlon-web-new/src/app.tsx index 41cd019b98..5cfd4391b5 100644 --- a/apps/tlon-web-new/src/app.tsx +++ b/apps/tlon-web-new/src/app.tsx @@ -8,6 +8,7 @@ import { useConfigureUrbitClient } from '@tloncorp/app/hooks/useConfigureUrbitCl import { useCurrentUserId } from '@tloncorp/app/hooks/useCurrentUser'; import { useFindSuggestedContacts } from '@tloncorp/app/hooks/useFindSuggestedContacts'; import { useIsDarkMode } from '@tloncorp/app/hooks/useIsDarkMode'; +import { loadConstants } from '@tloncorp/app/lib/constants'; import { checkDb, useMigrations } from '@tloncorp/app/lib/webDb'; import { BasePathNavigator } from '@tloncorp/app/navigation/BasePathNavigator'; import { @@ -35,6 +36,8 @@ import { preSig } from '@/logic/utils'; import { toggleDevTools, useLocalState, useShowDevTools } from '@/state/local'; import { useAnalyticsId, useLogActivity, useTheme } from '@/state/settings'; +loadConstants(); + const ReactQueryDevtoolsProduction = React.lazy(() => import('@tanstack/react-query-devtools/production').then((d) => ({ default: d.ReactQueryDevtools, diff --git a/apps/tlon-web/src/app.tsx b/apps/tlon-web/src/app.tsx index fda021a009..9712070bd3 100644 --- a/apps/tlon-web/src/app.tsx +++ b/apps/tlon-web/src/app.tsx @@ -1,4 +1,5 @@ // Copyright 2022, Tlon Corporation +import * as Toast from '@radix-ui/react-toast'; import { TooltipProvider } from '@radix-ui/react-tooltip'; import cookies from 'browser-cookies'; import { usePostHog } from 'posthog-js/react'; @@ -98,6 +99,7 @@ import ThreadVolumeDialog from './channels/ThreadVolumeDialog'; import MobileChatSearch from './chat/ChatSearch/MobileChatSearch'; import DevLog from './components/DevLog/DevLog'; import DevLogsView from './components/DevLog/DevLogView'; +import MobileAppToast from './components/MobileAppToast'; import ReportContent from './components/ReportContent'; import BlockedUsersDialog from './components/Settings/BlockedUsersDialog'; import BlockedUsersView from './components/Settings/BlockedUsersView'; @@ -639,6 +641,7 @@ const App = React.memo(function AppComponent() { return (
+ diff --git a/apps/tlon-web/src/components/MobileAppToast.tsx b/apps/tlon-web/src/components/MobileAppToast.tsx new file mode 100644 index 0000000000..f7e9efe6b1 --- /dev/null +++ b/apps/tlon-web/src/components/MobileAppToast.tsx @@ -0,0 +1,61 @@ +import * as Toast from '@radix-ui/react-toast'; +import { usePutEntryMutation, useMergedSettings } from '@/state/settings'; +import TlonIcon from './icons/TlonIcon'; + +export default function MobileAppToast() { + const { data: settings, isLoading } = useMergedSettings(); + const { mutate } = usePutEntryMutation({ + bucket: 'groups', + key: 'seenMobileAppToast' + }); + + const hasSeenToast = settings?.groups?.seenMobileAppToast ?? false; + + if (isLoading || hasSeenToast) { + return null; + } + + return ( + +
+ + +
+
+ + + The all-new Tlon Messenger mobile app is now available. + +
+
+ + App Store + + + Google Play + + +
+
+
+
+ +
+
+ ); +} diff --git a/apps/tlon-web/src/state/settings.ts b/apps/tlon-web/src/state/settings.ts index 472f1ba072..644d562252 100644 --- a/apps/tlon-web/src/state/settings.ts +++ b/apps/tlon-web/src/state/settings.ts @@ -109,6 +109,7 @@ export interface SettingsState { newGroupFlags: string[]; groupsNavState?: string; messagesNavState?: string; + seenMobileAppToast?: boolean; }; loaded: boolean; putEntry: (bucket: string, key: string, value: Value) => Promise; diff --git a/cosmos.imports.ts b/cosmos.imports.ts index c0c1d3de9d..fea49e81bd 100644 --- a/cosmos.imports.ts +++ b/cosmos.imports.ts @@ -27,45 +27,43 @@ import * as fixture20 from './packages/app/fixtures/GroupListItem.fixture'; import * as fixture21 from './packages/app/fixtures/GroupList.fixture'; import * as fixture22 from './packages/app/fixtures/GalleryPost.fixture'; import * as fixture23 from './packages/app/fixtures/Form.fixture'; -import * as fixture24 from './packages/app/fixtures/FindGroups.fixture'; -import * as fixture25 from './packages/app/fixtures/EditProfileScreen.fixture'; -import * as fixture26 from './packages/app/fixtures/CreateGroup.fixture'; -import * as fixture27 from './packages/app/fixtures/ContactList.fixture'; -import * as fixture28 from './packages/app/fixtures/ChatMessage.fixture'; -import * as fixture29 from './packages/app/fixtures/ChannelSwitcherSheet.fixture'; -import * as fixture30 from './packages/app/fixtures/ChannelHeader.fixture'; -import * as fixture31 from './packages/app/fixtures/ChannelDivider.fixture'; -import * as fixture32 from './packages/app/fixtures/Channel.fixture'; -import * as fixture33 from './packages/app/fixtures/Button.fixture'; -import * as fixture34 from './packages/app/fixtures/BlockSectionList.fixture'; -import * as fixture35 from './packages/app/fixtures/Avatar.fixture'; -import * as fixture36 from './packages/app/fixtures/AudioEmbed.fixture'; -import * as fixture37 from './packages/app/fixtures/AttachmentPreviewList.fixture'; -import * as fixture38 from './packages/app/fixtures/AddGroupSheet.fixture'; -import * as fixture39 from './packages/app/fixtures/Activity.fixture'; -import * as fixture40 from './packages/app/fixtures/DetailView/NotebookDetailView.fixture'; -import * as fixture41 from './packages/app/fixtures/DetailView/GalleryDetailView.fixture'; -import * as fixture42 from './packages/app/fixtures/DetailView/ChatDetailView.fixture'; -import * as fixture43 from './packages/app/fixtures/ActionSheet/SendPostRetrySheet.fixture'; -import * as fixture44 from './packages/app/fixtures/ActionSheet/ProfileSheet.fixture'; -import * as fixture45 from './packages/app/fixtures/ActionSheet/GroupPreviewSheet.fixture'; -import * as fixture46 from './packages/app/fixtures/ActionSheet/GroupJoinRequestSheet.fixture'; -import * as fixture47 from './packages/app/fixtures/ActionSheet/GenericActionSheet.fixture'; -import * as fixture48 from './packages/app/fixtures/ActionSheet/EditSectionNameSheet.fixture'; -import * as fixture49 from './packages/app/fixtures/ActionSheet/DeleteSheet.fixture'; -import * as fixture50 from './packages/app/fixtures/ActionSheet/CreateChannelSheet.fixture'; -import * as fixture51 from './packages/app/fixtures/ActionSheet/AttachmentSheet.fixture'; -import * as fixture52 from './packages/app/fixtures/ActionSheet/AddGalleryPostSheet.fixture'; -import * as fixture53 from './apps/tlon-mobile/src/App.fixture'; -import * as fixture54 from './apps/tlon-mobile/src/fixtures/SetNicknameScreen.fixture'; -import * as fixture55 from './apps/tlon-mobile/src/fixtures/Onboarding.fixture'; -import * as fixture56 from './apps/tlon-mobile/src/fixtures/InputToolbar.fixture'; +import * as fixture24 from './packages/app/fixtures/EditProfileScreen.fixture'; +import * as fixture25 from './packages/app/fixtures/CreateChatSheet.fixture'; +import * as fixture26 from './packages/app/fixtures/ContactList.fixture'; +import * as fixture27 from './packages/app/fixtures/ChatMessage.fixture'; +import * as fixture28 from './packages/app/fixtures/ChannelSwitcherSheet.fixture'; +import * as fixture29 from './packages/app/fixtures/ChannelHeader.fixture'; +import * as fixture30 from './packages/app/fixtures/ChannelDivider.fixture'; +import * as fixture31 from './packages/app/fixtures/Channel.fixture'; +import * as fixture32 from './packages/app/fixtures/Button.fixture'; +import * as fixture33 from './packages/app/fixtures/BlockSectionList.fixture'; +import * as fixture34 from './packages/app/fixtures/Avatar.fixture'; +import * as fixture35 from './packages/app/fixtures/AudioEmbed.fixture'; +import * as fixture36 from './packages/app/fixtures/AttachmentPreviewList.fixture'; +import * as fixture37 from './packages/app/fixtures/Activity.fixture'; +import * as fixture38 from './packages/app/fixtures/DetailView/NotebookDetailView.fixture'; +import * as fixture39 from './packages/app/fixtures/DetailView/GalleryDetailView.fixture'; +import * as fixture40 from './packages/app/fixtures/DetailView/ChatDetailView.fixture'; +import * as fixture41 from './packages/app/fixtures/ActionSheet/SendPostRetrySheet.fixture'; +import * as fixture42 from './packages/app/fixtures/ActionSheet/ProfileSheet.fixture'; +import * as fixture43 from './packages/app/fixtures/ActionSheet/GroupPreviewSheet.fixture'; +import * as fixture44 from './packages/app/fixtures/ActionSheet/GroupJoinRequestSheet.fixture'; +import * as fixture45 from './packages/app/fixtures/ActionSheet/GenericActionSheet.fixture'; +import * as fixture46 from './packages/app/fixtures/ActionSheet/EditSectionNameSheet.fixture'; +import * as fixture47 from './packages/app/fixtures/ActionSheet/DeleteSheet.fixture'; +import * as fixture48 from './packages/app/fixtures/ActionSheet/CreateChannelSheet.fixture'; +import * as fixture49 from './packages/app/fixtures/ActionSheet/AttachmentSheet.fixture'; +import * as fixture50 from './packages/app/fixtures/ActionSheet/AddGalleryPostSheet.fixture'; +import * as fixture51 from './apps/tlon-mobile/src/App.fixture'; +import * as fixture52 from './apps/tlon-mobile/src/fixtures/SetNicknameScreen.fixture'; +import * as fixture53 from './apps/tlon-mobile/src/fixtures/Onboarding.fixture'; +import * as fixture54 from './apps/tlon-mobile/src/fixtures/InputToolbar.fixture'; import * as decorator0 from './packages/app/fixtures/cosmos.decorator'; import * as decorator1 from './apps/tlon-mobile/src/fixtures/cosmos.decorator'; export const rendererConfig: RendererConfig = { - "playgroundUrl": "http://localhost:5001", + "playgroundUrl": "http://localhost:5002", "rendererUrl": null }; @@ -94,39 +92,37 @@ const fixtures = { 'packages/app/fixtures/GroupList.fixture.tsx': { module: fixture21 }, 'packages/app/fixtures/GalleryPost.fixture.tsx': { module: fixture22 }, 'packages/app/fixtures/Form.fixture.tsx': { module: fixture23 }, - 'packages/app/fixtures/FindGroups.fixture.tsx': { module: fixture24 }, - 'packages/app/fixtures/EditProfileScreen.fixture.tsx': { module: fixture25 }, - 'packages/app/fixtures/CreateGroup.fixture.tsx': { module: fixture26 }, - 'packages/app/fixtures/ContactList.fixture.tsx': { module: fixture27 }, - 'packages/app/fixtures/ChatMessage.fixture.tsx': { module: fixture28 }, - 'packages/app/fixtures/ChannelSwitcherSheet.fixture.tsx': { module: fixture29 }, - 'packages/app/fixtures/ChannelHeader.fixture.tsx': { module: fixture30 }, - 'packages/app/fixtures/ChannelDivider.fixture.tsx': { module: fixture31 }, - 'packages/app/fixtures/Channel.fixture.tsx': { module: fixture32 }, - 'packages/app/fixtures/Button.fixture.tsx': { module: fixture33 }, - 'packages/app/fixtures/BlockSectionList.fixture.tsx': { module: fixture34 }, - 'packages/app/fixtures/Avatar.fixture.tsx': { module: fixture35 }, - 'packages/app/fixtures/AudioEmbed.fixture.tsx': { module: fixture36 }, - 'packages/app/fixtures/AttachmentPreviewList.fixture.tsx': { module: fixture37 }, - 'packages/app/fixtures/AddGroupSheet.fixture.tsx': { module: fixture38 }, - 'packages/app/fixtures/Activity.fixture.tsx': { module: fixture39 }, - 'packages/app/fixtures/DetailView/NotebookDetailView.fixture.tsx': { module: fixture40 }, - 'packages/app/fixtures/DetailView/GalleryDetailView.fixture.tsx': { module: fixture41 }, - 'packages/app/fixtures/DetailView/ChatDetailView.fixture.tsx': { module: fixture42 }, - 'packages/app/fixtures/ActionSheet/SendPostRetrySheet.fixture.tsx': { module: fixture43 }, - 'packages/app/fixtures/ActionSheet/ProfileSheet.fixture.tsx': { module: fixture44 }, - 'packages/app/fixtures/ActionSheet/GroupPreviewSheet.fixture.tsx': { module: fixture45 }, - 'packages/app/fixtures/ActionSheet/GroupJoinRequestSheet.fixture.tsx': { module: fixture46 }, - 'packages/app/fixtures/ActionSheet/GenericActionSheet.fixture.tsx': { module: fixture47 }, - 'packages/app/fixtures/ActionSheet/EditSectionNameSheet.fixture.tsx': { module: fixture48 }, - 'packages/app/fixtures/ActionSheet/DeleteSheet.fixture.tsx': { module: fixture49 }, - 'packages/app/fixtures/ActionSheet/CreateChannelSheet.fixture.tsx': { module: fixture50 }, - 'packages/app/fixtures/ActionSheet/AttachmentSheet.fixture.tsx': { module: fixture51 }, - 'packages/app/fixtures/ActionSheet/AddGalleryPostSheet.fixture.tsx': { module: fixture52 }, - 'apps/tlon-mobile/src/App.fixture.tsx': { module: fixture53 }, - 'apps/tlon-mobile/src/fixtures/SetNicknameScreen.fixture.tsx': { module: fixture54 }, - 'apps/tlon-mobile/src/fixtures/Onboarding.fixture.tsx': { module: fixture55 }, - 'apps/tlon-mobile/src/fixtures/InputToolbar.fixture.tsx': { module: fixture56 } + 'packages/app/fixtures/EditProfileScreen.fixture.tsx': { module: fixture24 }, + 'packages/app/fixtures/CreateChatSheet.fixture.tsx': { module: fixture25 }, + 'packages/app/fixtures/ContactList.fixture.tsx': { module: fixture26 }, + 'packages/app/fixtures/ChatMessage.fixture.tsx': { module: fixture27 }, + 'packages/app/fixtures/ChannelSwitcherSheet.fixture.tsx': { module: fixture28 }, + 'packages/app/fixtures/ChannelHeader.fixture.tsx': { module: fixture29 }, + 'packages/app/fixtures/ChannelDivider.fixture.tsx': { module: fixture30 }, + 'packages/app/fixtures/Channel.fixture.tsx': { module: fixture31 }, + 'packages/app/fixtures/Button.fixture.tsx': { module: fixture32 }, + 'packages/app/fixtures/BlockSectionList.fixture.tsx': { module: fixture33 }, + 'packages/app/fixtures/Avatar.fixture.tsx': { module: fixture34 }, + 'packages/app/fixtures/AudioEmbed.fixture.tsx': { module: fixture35 }, + 'packages/app/fixtures/AttachmentPreviewList.fixture.tsx': { module: fixture36 }, + 'packages/app/fixtures/Activity.fixture.tsx': { module: fixture37 }, + 'packages/app/fixtures/DetailView/NotebookDetailView.fixture.tsx': { module: fixture38 }, + 'packages/app/fixtures/DetailView/GalleryDetailView.fixture.tsx': { module: fixture39 }, + 'packages/app/fixtures/DetailView/ChatDetailView.fixture.tsx': { module: fixture40 }, + 'packages/app/fixtures/ActionSheet/SendPostRetrySheet.fixture.tsx': { module: fixture41 }, + 'packages/app/fixtures/ActionSheet/ProfileSheet.fixture.tsx': { module: fixture42 }, + 'packages/app/fixtures/ActionSheet/GroupPreviewSheet.fixture.tsx': { module: fixture43 }, + 'packages/app/fixtures/ActionSheet/GroupJoinRequestSheet.fixture.tsx': { module: fixture44 }, + 'packages/app/fixtures/ActionSheet/GenericActionSheet.fixture.tsx': { module: fixture45 }, + 'packages/app/fixtures/ActionSheet/EditSectionNameSheet.fixture.tsx': { module: fixture46 }, + 'packages/app/fixtures/ActionSheet/DeleteSheet.fixture.tsx': { module: fixture47 }, + 'packages/app/fixtures/ActionSheet/CreateChannelSheet.fixture.tsx': { module: fixture48 }, + 'packages/app/fixtures/ActionSheet/AttachmentSheet.fixture.tsx': { module: fixture49 }, + 'packages/app/fixtures/ActionSheet/AddGalleryPostSheet.fixture.tsx': { module: fixture50 }, + 'apps/tlon-mobile/src/App.fixture.tsx': { module: fixture51 }, + 'apps/tlon-mobile/src/fixtures/SetNicknameScreen.fixture.tsx': { module: fixture52 }, + 'apps/tlon-mobile/src/fixtures/Onboarding.fixture.tsx': { module: fixture53 }, + 'apps/tlon-mobile/src/fixtures/InputToolbar.fixture.tsx': { module: fixture54 } }; const decorators = { diff --git a/desk/app/profile.hoon b/desk/app/profile.hoon index 1dbca2f74a..223d295a0f 100644 --- a/desk/app/profile.hoon +++ b/desk/app/profile.hoon @@ -304,6 +304,11 @@ :~ [%pass /contacts/ours %agent [our.bowl %contacts] %leave ~] [%pass /contacts/news %agent [our.bowl %contacts] %watch /news] == + :: ensure contacts subscription is in place + :: + =? caz &(!?=(%0 ver) !(~(has by wex.bowl) /contacts/news our.bowl %contacts)) + %+ snoc caz + [%pass /contacts/news %agent [our.bowl %contacts] %watch /news] [caz this] :: +$ versioned-state diff --git a/desk/app/profile/widgets.hoon b/desk/app/profile/widgets.hoon index bd28e626b8..0cb4d43deb 100644 --- a/desk/app/profile/widgets.hoon +++ b/desk/app/profile/widgets.hoon @@ -9,15 +9,15 @@ == ::NOTE can't quite make a nice helper for this, wetness not wet enough... =/ nickname=(unit @t) =+ a=(~(gut by contact) %nickname %text '') - ?:(&(?=(%text -.a) !=('' +.a)) `+.a ~) + ?:(&(?=([%text *] a) !=('' +.a)) `+.a ~) =/ bio=(unit @t) =+ a=(~(gut by contact) %bio %text '') - ?:(&(?=(%text -.a) !=('' +.a)) `+.a ~) + ?:(&(?=([%text *] a) !=('' +.a)) `+.a ~) =/ color=@ux =+ a=(~(gut by contact) %color %tint 0x0) - ?:(?=(%tint -.a) +.a 0x0) + ?:(?=([%tint *] a) +.a 0x0) =/ avatar=(unit @ta) =+ a=(~(gut by contact) %avatar %look '') - ?:(&(?=(%look -.a) !=('' +.a)) `+.a ~) + ?:(&(?=([%look *] a) !=('' +.a)) `+.a ~) =/ cover=(unit @ta) =+ a=(~(gut by contact) %cover %look '') - ?:(&(?=(%look -.a) !=('' +.a)) `+.a ~) + ?:(&(?=([%look *] a) !=('' +.a)) `+.a ~) |^ %- ~(gas by *(map term [%0 @t %marl marl])) :~ [%profile %0 'Profile Header' %marl profile-widget] [%profile-bio %0 'Profile Bio' %marl profile-bio] diff --git a/desk/desk.docket-0 b/desk/desk.docket-0 index f6974de976..2f38911dd5 100644 --- a/desk/desk.docket-0 +++ b/desk/desk.docket-0 @@ -2,7 +2,7 @@ info+'Start, host, and cultivate communities. Own your communications, organize your resources, and share documents. Tlon is a decentralized platform that offers a full, communal suite of tools for messaging, writing and sharing media with others.' color+0xde.dede image+'https://bootstrap.urbit.org/tlon.svg?v=1' - glob-http+['https://bootstrap.urbit.org/glob-0v7.7donp.7htv6.tp9mc.lbvp8.r4i3h.glob' 0v7.7donp.7htv6.tp9mc.lbvp8.r4i3h] + glob-http+['https://bootstrap.urbit.org/glob-0v2.r02jf.e68sa.0rn97.fka8l.rqgj8.glob' 0v2.r02jf.e68sa.0rn97.fka8l.rqgj8] base+'groups' version+[6 7 0] website+'https://tlon.io' diff --git a/package.json b/package.json index ccb99fd7bd..873dd1c435 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,6 @@ "@urbit/http-api@3.1.0-dev-3": "patches/@urbit__http-api@3.1.0-dev-3.patch", "tailwind-rn@4.2.0": "patches/tailwind-rn@4.2.0.patch", "usehooks-ts@2.6.0": "patches/usehooks-ts@2.6.0.patch", - "react-native-storage@1.0.1": "patches/react-native-storage@1.0.1.patch", "react-native-reanimated@3.8.1": "patches/react-native-reanimated@3.8.1.patch", "@10play/tentap-editor@0.4.55": "patches/@10play__tentap-editor@0.4.55.patch", "any-ascii@0.3.2": "patches/any-ascii@0.3.2.patch", diff --git a/packages/app/constants.ts b/packages/app/constants.ts index 62c1e2d0a8..2035238561 100644 --- a/packages/app/constants.ts +++ b/packages/app/constants.ts @@ -49,3 +49,32 @@ export const BRANCH_KEY = extra.branchKey ?? ''; export const BRANCH_DOMAIN = extra.branchDomain ?? ''; export const INVITE_SERVICE_ENDPOINT = extra.inviteServiceEndpoint ?? ''; export const INVITE_SERVICE_IS_DEV = extra.inviteServiceIsDev === 'true'; + +export const ENV_VARS = { + NOTIFY_PROVIDER, + NOTIFY_SERVICE, + POST_HOG_API_KEY, + API_URL, + API_AUTH_USERNAME, + API_AUTH_PASSWORD, + RECAPTCHA_SITE_KEY, + SHIP_URL_PATTERN, + DEFAULT_LURE, + DEFAULT_PRIORITY_TOKEN, + DEFAULT_TLON_LOGIN_EMAIL, + DEFAULT_TLON_LOGIN_PASSWORD, + DEFAULT_INVITE_LINK_URL, + DEFAULT_SHIP_LOGIN_URL, + DEFAULT_SHIP_LOGIN_ACCESS_CODE, + DEFAULT_ONBOARDING_PASSWORD, + DEFAULT_ONBOARDING_TLON_EMAIL, + DEFAULT_ONBOARDING_NICKNAME, + DEFAULT_ONBOARDING_PHONE_NUMBER, + ENABLED_LOGGERS, + IGNORE_COSMOS, + TLON_EMPLOYEE_GROUP, + BRANCH_KEY, + BRANCH_DOMAIN, + INVITE_SERVICE_ENDPOINT, + INVITE_SERVICE_IS_DEV, +}; diff --git a/packages/app/contexts/branch.tsx b/packages/app/contexts/branch.tsx index 3c6cba6e60..528e89b56c 100644 --- a/packages/app/contexts/branch.tsx +++ b/packages/app/contexts/branch.tsx @@ -1,10 +1,6 @@ import { createDevLogger } from '@tloncorp/shared'; -import { - AppInvite, - DeepLinkData, - Lure, - extractLureMetadata, -} from '@tloncorp/shared/logic'; +import { storage } from '@tloncorp/shared/db'; +import { AppInvite, Lure, extractLureMetadata } from '@tloncorp/shared/logic'; import { type ReactNode, createContext, @@ -17,7 +13,6 @@ import branch from 'react-native-branch'; import { DEFAULT_LURE, DEFAULT_PRIORITY_TOKEN } from '../constants'; import { useGroupNavigation } from '../hooks/useGroupNavigation'; -import storage from '../lib/storage'; import { getPathFromWer } from '../utils/string'; import { useShip } from './ship'; @@ -37,26 +32,8 @@ const INITIAL_STATE: State = { priorityToken: undefined, }; -const STORAGE_KEY = 'lure'; - const logger = createDevLogger('deeplink', true); -const saveLure = async (lure: Lure) => - storage.save({ key: STORAGE_KEY, data: JSON.stringify(lure) }); - -const getSavedLure = async () => { - try { - const lureString = await storage.load({ - key: STORAGE_KEY, - }); - return lureString ? (JSON.parse(lureString) as Lure) : undefined; - } catch (err) { - return undefined; - } -}; - -const clearSavedLure = async () => storage.remove({ key: STORAGE_KEY }); - export const Context = createContext({} as ContextValue); export const useBranch = () => { @@ -153,7 +130,7 @@ export const BranchProvider = ({ children }: { children: ReactNode }) => { ...nextLure, deepLinkPath: undefined, }); - saveLure(nextLure); + storage.invitation.setValue(nextLure); } else if (params.wer) { // Link had a wer (deep link) field embedded const deepLinkPath = getPathFromWer(params.wer as string); @@ -170,7 +147,7 @@ export const BranchProvider = ({ children }: { children: ReactNode }) => { // Check for saved lure (async () => { - const nextLure = await getSavedLure(); + const nextLure = await storage.invitation.getValue(); if (nextLure) { console.debug('[branch] Detected saved lure:', nextLure.lure); setState({ @@ -200,7 +177,7 @@ export const BranchProvider = ({ children }: { children: ReactNode }) => { ...nextLure, deepLinkPath: undefined, }); - saveLure(nextLure); + storage.invitation.setValue(nextLure); }, [isAuthenticated] ); @@ -212,7 +189,7 @@ export const BranchProvider = ({ children }: { children: ReactNode }) => { lure: undefined, priorityToken: undefined, })); - clearSavedLure(); + storage.invitation.resetValue(); }, []); const clearDeepLink = useCallback(() => { diff --git a/packages/app/contexts/ship.tsx b/packages/app/contexts/ship.tsx index 7da4043ebe..8494b3864d 100644 --- a/packages/app/contexts/ship.tsx +++ b/packages/app/contexts/ship.tsx @@ -1,4 +1,5 @@ import crashlytics from '@react-native-firebase/crashlytics'; +import { ShipInfo, storage } from '@tloncorp/shared/db'; import { preSig } from '@urbit/aura'; import type { ReactNode } from 'react'; import { @@ -10,18 +11,10 @@ import { } from 'react'; import { NativeModules } from 'react-native'; -import storage from '../lib/storage'; import { transformShipURL } from '../utils/string'; const { UrbitModule } = NativeModules; -export type ShipInfo = { - authType: 'self' | 'hosted'; - ship: string | undefined; - shipUrl: string | undefined; - authCookie: string | undefined; -}; - type State = ShipInfo & { contactId: string | undefined; isLoading: boolean; @@ -61,7 +54,7 @@ export const ShipProvider = ({ children }: { children: ReactNode }) => { // Clear all saved ship info if either required field is empty if (!ship || !shipUrl) { // Remove from React Native storage - clearShipInfo(); + storage.shipInfo.resetValue(); // Clear context state setShipInfo(emptyShip); @@ -81,7 +74,7 @@ export const ShipProvider = ({ children }: { children: ReactNode }) => { }; // Save to React Native stoage - saveShipInfo(nextShipInfo); + storage.shipInfo.setValue(nextShipInfo); // Save context state setShipInfo(nextShipInfo); @@ -108,7 +101,10 @@ export const ShipProvider = ({ children }: { children: ReactNode }) => { const fetchedAuthCookie = response.headers.get('set-cookie'); if (fetchedAuthCookie) { setShipInfo({ ...nextShipInfo, authCookie: fetchedAuthCookie }); - saveShipInfo({ ...nextShipInfo, authCookie: fetchedAuthCookie }); + storage.shipInfo.setValue({ + ...nextShipInfo, + authCookie: fetchedAuthCookie, + }); // Save to native storage UrbitModule.setUrbit(ship, normalizedShipUrl, fetchedAuthCookie); } @@ -123,7 +119,7 @@ export const ShipProvider = ({ children }: { children: ReactNode }) => { useEffect(() => { const loadConnection = async () => { try { - const storedShipInfo = await loadShipInfo(); + const storedShipInfo = await storage.shipInfo.getValue(); if (storedShipInfo) { setShip(storedShipInfo); } else { @@ -159,17 +155,3 @@ export const ShipProvider = ({ children }: { children: ReactNode }) => { ); }; - -const shipInfoKey = 'store'; - -export const saveShipInfo = (shipInfo: ShipInfo) => { - return storage.save({ key: shipInfoKey, data: shipInfo }); -}; - -export const loadShipInfo = () => { - return storage.load({ key: shipInfoKey }); -}; - -export const clearShipInfo = () => { - return storage.remove({ key: shipInfoKey }); -}; diff --git a/packages/app/features/groups/GroupMembersScreen.tsx b/packages/app/features/groups/GroupMembersScreen.tsx index 1dae5acd30..06f69b1627 100644 --- a/packages/app/features/groups/GroupMembersScreen.tsx +++ b/packages/app/features/groups/GroupMembersScreen.tsx @@ -5,7 +5,7 @@ import { useCallback } from 'react'; import { useCurrentUserId } from '../../hooks/useCurrentUser'; import { useGroupContext } from '../../hooks/useGroupContext'; import { GroupSettingsStackParamList } from '../../navigation/types'; -import { useResetToDm } from '../../navigation/utils'; +import { useRootNavigation } from '../../navigation/utils'; type Props = NativeStackScreenProps< GroupSettingsStackParamList, @@ -31,7 +31,7 @@ export function GroupMembersScreen({ route, navigation }: Props) { const currentUserId = useCurrentUserId(); - const resetToDm = useResetToDm(); + const { resetToDm } = useRootNavigation(); const handleGoToDm = useCallback( async (participants: string[]) => { diff --git a/packages/app/features/groups/GroupMetaScreen.tsx b/packages/app/features/groups/GroupMetaScreen.tsx index 2c8876cf96..a7d3f278c9 100644 --- a/packages/app/features/groups/GroupMetaScreen.tsx +++ b/packages/app/features/groups/GroupMetaScreen.tsx @@ -7,6 +7,8 @@ import { Button, DeleteSheet, MetaEditorScreenView, + YStack, + useGroupTitle, } from '@tloncorp/ui'; import { useCallback, useState } from 'react'; @@ -57,6 +59,8 @@ export function GroupMetaScreen(props: Props) { props.navigateToHome(); }, [deleteGroup, props]); + const title = useGroupTitle(group); + return ( - - + + + + ); diff --git a/packages/app/features/settings/AppInfoScreen.tsx b/packages/app/features/settings/AppInfoScreen.tsx index 39c9dace94..027b1dda27 100644 --- a/packages/app/features/settings/AppInfoScreen.tsx +++ b/packages/app/features/settings/AppInfoScreen.tsx @@ -24,7 +24,6 @@ import { ScrollView } from 'react-native-gesture-handler'; import { NOTIFY_PROVIDER, NOTIFY_SERVICE } from '../../constants'; import { useCurrentUserId } from '../../hooks/useCurrentUser'; import { useTelemetry } from '../../hooks/useTelemetry'; -import { setDebug } from '../../lib/debug'; import { getEasUpdateDisplay } from '../../lib/platformHelpers'; import { RootStackParamList } from '../../navigation/types'; @@ -54,7 +53,13 @@ ${JSON.stringify(appInfo)} export function AppInfoScreen(props: Props) { const { data: appInfo } = store.useAppInfo(); - const { enabled, logs, logId, uploadLogs } = useDebugStore(); + const { + enabled, + logs, + logId, + uploadLogs, + toggle: setDebugEnabled, + } = useDebugStore(); const easUpdateDisplay = useMemo(() => getEasUpdateDisplay(Updates), []); const [hasClients, setHasClients] = useState(true); const telemetry = useTelemetry(); @@ -82,7 +87,7 @@ export function AppInfoScreen(props: Props) { }, []); const toggleDebugFlag = useCallback((enabled: boolean) => { - setDebug(enabled); + setDebugEnabled(enabled); if (!enabled) { return; } diff --git a/packages/app/features/settings/ThemeScreen.tsx b/packages/app/features/settings/ThemeScreen.tsx index 824948c006..532d1e9bcb 100644 --- a/packages/app/features/settings/ThemeScreen.tsx +++ b/packages/app/features/settings/ThemeScreen.tsx @@ -12,6 +12,7 @@ import { import { useContext, useEffect, useState } from 'react'; import { ScrollView, YStack } from 'tamagui'; import type { ThemeName } from 'tamagui'; +import { useTheme } from 'tamagui'; import { useIsDarkMode } from '../../hooks/useIsDarkMode'; import { RootStackParamList } from '../../navigation/types'; @@ -20,6 +21,7 @@ import { ThemeContext, clearTheme, setTheme } from '../../provider'; type Props = NativeStackScreenProps; export function ThemeScreen(props: Props) { + const theme = useTheme(); const { setActiveTheme } = useContext(ThemeContext); const isDarkMode = useIsDarkMode(); const [selectedTheme, setSelectedTheme] = useState( @@ -71,7 +73,7 @@ export function ThemeScreen(props: Props) { }, []); return ( - + props.navigation.goBack()} diff --git a/packages/app/features/top/ActivityScreen.tsx b/packages/app/features/top/ActivityScreen.tsx index 0acc1fdcc2..5953075a4f 100644 --- a/packages/app/features/top/ActivityScreen.tsx +++ b/packages/app/features/top/ActivityScreen.tsx @@ -4,21 +4,23 @@ import * as db from '@tloncorp/shared/db'; import * as store from '@tloncorp/shared/store'; import { ActivityScreenView, NavBarView, View } from '@tloncorp/ui'; import { useCallback, useMemo } from 'react'; +import { useTheme } from 'tamagui'; -// import ErrorBoundary from '../../ErrorBoundary'; import { useCurrentUserId } from '../../hooks/useCurrentUser'; import { useGroupActions } from '../../hooks/useGroupActions'; import { useFeatureFlag } from '../../lib/featureFlags'; import { RootStackParamList } from '../../navigation/types'; -import { screenNameFromChannelId } from '../../navigation/utils'; +import { useRootNavigation } from '../../navigation/utils'; type Props = NativeStackScreenProps; export function ActivityScreen(props: Props) { + const theme = useTheme(); const isFocused = useIsFocused(); const currentUserId = useCurrentUserId(); const [contactsTabEnabled] = useFeatureFlag('contactsTab'); const { performGroupAction } = useGroupActions(); + const { navigateToChannel, navigateToPost } = useRootNavigation(); const allFetcher = store.useInfiniteBucketedActivity('all'); const mentionsFetcher = store.useInfiniteBucketedActivity('mentions'); @@ -37,13 +39,9 @@ export function ActivityScreen(props: Props) { const handleGoToChannel = useCallback( (channel: db.Channel, selectedPostId?: string) => { - const screenName = screenNameFromChannelId(channel.id); - props.navigation.navigate(screenName, { - channelId: channel.id, - selectedPostId, - }); + navigateToChannel(channel, selectedPostId); }, - [props.navigation] + [navigateToChannel] ); // TODO: if diary or gallery, figure out a way to pop open the comment @@ -51,13 +49,9 @@ export function ActivityScreen(props: Props) { const handleGoToThread = useCallback( (post: db.Post) => { // TODO: we have no way to route to specific thread message rn - props.navigation.navigate('Post', { - postId: post.id, - authorId: post.authorId, - channelId: post.channelId, - }); + navigateToPost(post); }, - [props.navigation] + [navigateToPost] ); const handleGoToGroup = useCallback( @@ -77,9 +71,8 @@ export function ActivityScreen(props: Props) { }, [props.navigation] ); - return ( - + { - if (group?.isNew) { - store.markGroupVisited(group); - } - }, [group]) - ); - useFocusEffect( - useCallback(() => { - if (channel && !channel.isPendingChannel) { - store.syncChannelThreadUnreads(channel.id, { + if (!channelIsPending) { + store.syncChannelThreadUnreads(channelId, { priority: store.SyncPriority.High, }); - if (group) { - // Update the last visited channel in the group so we can return to it - // when we come back to the group - db.updateGroup({ - id: group.id, - lastVisitedChannelId: channel.id, - }); - } } // Mark the channel as visited when we unfocus/leave this screen () => { - if (channel) { - store.markChannelVisited(channel); + if (!channelIsPending) { + store.markChannelVisited(channelId); } }; - }, [channel, group]) + }, [channelId, channelIsPending]) + ); + + const groupIsNew = group?.isNew; + useFocusEffect( + useCallback(() => { + // Mark group visited on enter if new + if (groupId && groupIsNew) { + store.markGroupVisited(groupId); + } + }, [groupId, groupIsNew]) + ); + + useFocusEffect( + useCallback(() => { + if (groupId) { + // Update the last visited channel in the group so we can return to it + // when we come back to the group + db.updateGroup({ + id: groupId, + lastVisitedChannelId: channelId, + }); + } + }, [groupId, channelId]) ); const [channelNavOpen, setChannelNavOpen] = React.useState(false); @@ -348,54 +360,60 @@ export default function ChannelScreen(props: Props) { return ( { setInviteSheetGroup(group); }} {...chatOptionsNavProps} > - + + + {group && ( <> ; @@ -16,12 +24,19 @@ export default function ChannelSearchScreen(props: Props) { const channelQuery = useChannel({ id: channelId, }); + const groupQuery = useGroup({ + id: groupId, + }); + const isSingleChannelGroup = groupQuery.data?.channels.length === 1; + const groupTitle = useGroupTitle(groupQuery.data); + const channelTitle = useChannelTitle(channelQuery.data ?? null); + const title = isSingleChannelGroup ? groupTitle : channelTitle; const [query, setQuery] = useState(''); const { posts, loading, errored, hasMore, loadMore, searchedThroughDate } = useChannelSearch(channelId, query); - const resetToChannel = useResetToChannel(); + const { resetToChannel } = useRootNavigation(); const navigateToPost = useCallback( (post: db.Post) => { @@ -47,7 +62,7 @@ export default function ChannelSearchScreen(props: Props) { + )} + + + ); +} + +function useCreateChat() { + const [isCreatingChat, setIsCreatingChat] = useState(false); + const [createChatError, setCreateChatError] = useState(null); + const { navigateToGroup, navigateToChannel } = useRootNavigation(); + const createChat = useCallback( + async (params: CreateChatParams) => { + setIsCreatingChat(true); + try { + if (params.type === 'dm') { + const channel = await store.upsertDmChannel({ + participants: [params.contactId], + }); + navigateToChannel(channel); + } else { + const group = await store.createGroup({ + memberIds: params.contactIds, + }); + navigateToGroup(group.id); + } + return true; + } catch (e) { + trackError(e); + setCreateChatError(e.message); + return false; + } finally { + setIsCreatingChat(false); + } + }, + [navigateToChannel, navigateToGroup] + ); + + return { isCreatingChat, createChatError, createChat }; +} diff --git a/packages/app/features/top/CreateGroupScreen.tsx b/packages/app/features/top/CreateGroupScreen.tsx deleted file mode 100644 index 80762df562..0000000000 --- a/packages/app/features/top/CreateGroupScreen.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import type { NativeStackScreenProps } from '@react-navigation/native-stack'; -import type * as db from '@tloncorp/shared/db'; -import { CreateGroupView } from '@tloncorp/ui'; -import { useCallback } from 'react'; - -import type { RootStackParamList } from '../../navigation/types'; - -type Props = NativeStackScreenProps; - -export function CreateGroupScreen(props: Props) { - const handleGoToChannel = useCallback( - (channel: db.Channel) => { - props.navigation.reset({ - index: 1, - routes: [ - { name: 'ChatList' }, - { name: 'Channel', params: { channelId: channel.id } }, - ], - }); - }, - [props.navigation] - ); - - return ( - props.navigation.goBack()} - navigateToChannel={handleGoToChannel} - /> - ); -} diff --git a/packages/app/features/top/FindGroupsScreen.tsx b/packages/app/features/top/FindGroupsScreen.tsx deleted file mode 100644 index 2290e44ff0..0000000000 --- a/packages/app/features/top/FindGroupsScreen.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import type { NativeStackScreenProps } from '@react-navigation/native-stack'; -import { FindGroupsView } from '@tloncorp/ui'; - -import { useGroupNavigation } from '../../hooks/useGroupNavigation'; -import type { RootStackParamList } from '../../navigation/types'; - -type Props = NativeStackScreenProps; - -export function FindGroupsScreen({ navigation }: Props) { - const { goToContactHostedGroups } = useGroupNavigation(); - return ( - navigation.goBack()} - goToContactHostedGroups={goToContactHostedGroups} - /> - ); -} diff --git a/packages/app/features/top/GroupChannelsScreen.tsx b/packages/app/features/top/GroupChannelsScreen.tsx index f6e2a1ab91..a7c143e533 100644 --- a/packages/app/features/top/GroupChannelsScreen.tsx +++ b/packages/app/features/top/GroupChannelsScreen.tsx @@ -1,8 +1,4 @@ -import { - NavigationProp, - useIsFocused, - useNavigation, -} from '@react-navigation/native'; +import { useIsFocused } from '@react-navigation/native'; import type { NativeStackScreenProps } from '@react-navigation/native-stack'; import * as db from '@tloncorp/shared/db'; import * as store from '@tloncorp/shared/store'; @@ -11,6 +7,7 @@ import { GroupChannelsScreenView, InviteUsersSheet, NavigationProvider, + useIsWindowNarrow, } from '@tloncorp/ui'; import { useCallback, useState } from 'react'; @@ -18,6 +15,7 @@ import { useChatSettingsNavigation } from '../../hooks/useChatSettingsNavigation import { useGroupContext } from '../../hooks/useGroupContext'; import { useFeatureFlag } from '../../lib/featureFlags'; import type { RootStackParamList } from '../../navigation/types'; +import { useRootNavigation } from '../../navigation/utils'; type Props = NativeStackScreenProps; @@ -32,7 +30,6 @@ export function GroupChannelsScreenContent({ groupId: string; focusedChannelId?: string; }) { - const navigation = useNavigation>(); const isFocused = useIsFocused(); const [inviteSheetGroup, setInviteSheetGroup] = useState( null @@ -41,20 +38,27 @@ export function GroupChannelsScreenContent({ const { data: unjoinedChannels } = store.useUnjoinedGroupChannels( group?.id ?? '' ); + const { navigateToChannel, navigation } = useRootNavigation(); + const isWindowNarrow = useIsWindowNarrow(); const handleChannelSelected = useCallback( (channel: db.Channel) => { - navigation.navigate('Channel', { - channelId: channel.id, - groupId: channel.groupId ?? undefined, - }); + navigateToChannel(channel); }, - [navigation] + [navigateToChannel] ); const handleGoBackPressed = useCallback(() => { - navigation.navigate('ChatList'); - }, [navigation]); + if (isWindowNarrow) { + navigation.navigate('ChatList'); + } else { + // Reset is necessary on desktop to ensure that the ChannelStack is cleared + navigation.reset({ + index: 0, + routes: [{ name: 'Home' }], + }); + } + }, [navigation, isWindowNarrow]); const [enableCustomChannels] = useFeatureFlag('customChannelCreation'); diff --git a/packages/app/features/top/PostScreen.tsx b/packages/app/features/top/PostScreen.tsx index 178151584a..4512d65f3e 100644 --- a/packages/app/features/top/PostScreen.tsx +++ b/packages/app/features/top/PostScreen.tsx @@ -4,6 +4,7 @@ import * as db from '@tloncorp/shared/db'; import * as store from '@tloncorp/shared/store'; import * as urbit from '@tloncorp/shared/urbit'; import { + AttachmentProvider, ChatOptionsProvider, PostScreenView, useCurrentUserId, @@ -15,6 +16,7 @@ import { useChatSettingsNavigation } from '../../hooks/useChatSettingsNavigation import { useGroupActions } from '../../hooks/useGroupActions'; import { useFeatureFlag } from '../../lib/featureFlags'; import type { RootStackParamList } from '../../navigation/types'; +import { useRootNavigation } from '../../navigation/utils'; type Props = NativeStackScreenProps; @@ -139,38 +141,52 @@ export default function PostScreen(props: Props) { [props.navigation] ); + const { navigateBackFromPost } = useRootNavigation(); + const handleGoBack = useCallback(() => { + if (!channel) { + props.navigation.goBack(); + return; + } + // This allows us to navigate to the channel and highlight the message in the scroller + // OR navigate back to Activity if we came from there + navigateBackFromPost(channel!, postId); + }, [channel, postId, props.navigation, navigateBackFromPost]); + const chatOptionsNavProps = useChatSettingsNavigation(); return currentUserId && channel && post ? ( - - + + + + ) : null; } diff --git a/packages/app/features/top/UserProfileScreen.tsx b/packages/app/features/top/UserProfileScreen.tsx index 9efd7da170..51f6ccf6d6 100644 --- a/packages/app/features/top/UserProfileScreen.tsx +++ b/packages/app/features/top/UserProfileScreen.tsx @@ -15,7 +15,7 @@ import { useCallback } from 'react'; import { useCurrentUserId } from '../../hooks/useCurrentUser'; import { useGroupActions } from '../../hooks/useGroupActions'; import { RootStackParamList } from '../../navigation/types'; -import { useResetToDm } from '../../navigation/utils'; +import { useRootNavigation } from '../../navigation/utils'; import { useConnectionStatus } from './useConnectionStatus'; type Props = NativeStackScreenProps; @@ -27,7 +27,7 @@ export function UserProfileScreen({ route: { params }, navigation }: Props) { const { data: contacts } = store.useContacts(); const connectionStatus = useConnectionStatus(userId); const [selectedGroup, setSelectedGroup] = useState(null); - const resetToDm = useResetToDm(); + const { resetToDm } = useRootNavigation(); const handleGoToDm = useCallback( async (participants: string[]) => { diff --git a/packages/app/fixtures/Channel.fixture.tsx b/packages/app/fixtures/Channel.fixture.tsx index 6b38612af4..0d282865f8 100644 --- a/packages/app/fixtures/Channel.fixture.tsx +++ b/packages/app/fixtures/Channel.fixture.tsx @@ -109,7 +109,7 @@ const baseProps: ComponentProps = { storeDraft: () => {}, clearDraft: () => {}, canUpload: true, - onPressRetry: () => {}, + onPressRetry: async () => {}, onPressDelete: () => {}, } as const; diff --git a/packages/app/fixtures/AddGroupSheet.fixture.tsx b/packages/app/fixtures/CreateChatSheet.fixture.tsx similarity index 51% rename from packages/app/fixtures/AddGroupSheet.fixture.tsx rename to packages/app/fixtures/CreateChatSheet.fixture.tsx index acc727ee65..37c0fc36b4 100644 --- a/packages/app/fixtures/AddGroupSheet.fixture.tsx +++ b/packages/app/fixtures/CreateChatSheet.fixture.tsx @@ -1,5 +1,6 @@ -import { AddGroupSheet, AppDataContextProvider } from '@tloncorp/ui'; +import { AppDataContextProvider } from '@tloncorp/ui'; +import { CreateChatSheet } from '../features/top/CreateChatSheet'; import { FixtureWrapper } from './FixtureWrapper'; import { initialContacts } from './fakeData'; @@ -7,13 +8,7 @@ export default { basic: ( - {}} - onGoToDm={() => {}} - navigateToFindGroups={() => {}} - navigateToCreateGroup={() => {}} - /> + ), diff --git a/packages/app/fixtures/CreateGroup.fixture.tsx b/packages/app/fixtures/CreateGroup.fixture.tsx deleted file mode 100644 index f79a637833..0000000000 --- a/packages/app/fixtures/CreateGroup.fixture.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { AppDataContextProvider, CreateGroupView } from '@tloncorp/ui'; - -import { FixtureWrapper } from './FixtureWrapper'; -import { initialContacts } from './fakeData'; - -export default { - basic: ( - - - {}} navigateToChannel={() => {}} /> - - - ), -}; diff --git a/packages/app/fixtures/DetailView/detailViewFixtureBase.tsx b/packages/app/fixtures/DetailView/detailViewFixtureBase.tsx index 3d5e20798d..ba8a5576fc 100644 --- a/packages/app/fixtures/DetailView/detailViewFixtureBase.tsx +++ b/packages/app/fixtures/DetailView/detailViewFixtureBase.tsx @@ -42,7 +42,7 @@ export const DetailViewFixture = ({ isLoadingPosts={false} channel={channel} sendReply={async () => {}} - onPressRetry={() => {}} + onPressRetry={async () => {}} onPressDelete={() => {}} groupMembers={[]} negotiationMatch={true} diff --git a/packages/app/fixtures/FindGroups.fixture.tsx b/packages/app/fixtures/FindGroups.fixture.tsx deleted file mode 100644 index e6ff61f75c..0000000000 --- a/packages/app/fixtures/FindGroups.fixture.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { AppDataContextProvider, FindGroupsView } from '@tloncorp/ui'; - -import { FixtureWrapper } from './FixtureWrapper'; -import { initialContacts } from './fakeData'; - -export default { - basic: ( - - - {}} goToContactHostedGroups={() => {}} /> - - - ), -}; diff --git a/packages/app/fixtures/PostScreen.fixture.tsx b/packages/app/fixtures/PostScreen.fixture.tsx index 65f41277bf..c60c00a697 100644 --- a/packages/app/fixtures/PostScreen.fixture.tsx +++ b/packages/app/fixtures/PostScreen.fixture.tsx @@ -22,7 +22,7 @@ export default ( handleGoToUserProfile={() => {}} isLoadingPosts={false} editPost={async () => {}} - onPressRetry={() => {}} + onPressRetry={async () => {}} onPressDelete={() => {}} editingPost={undefined} negotiationMatch={true} diff --git a/packages/app/hooks/useBootSequence.ts b/packages/app/hooks/useBootSequence.ts index 747901dac4..d905d8d475 100644 --- a/packages/app/hooks/useBootSequence.ts +++ b/packages/app/hooks/useBootSequence.ts @@ -150,7 +150,10 @@ export function useBootSequence({ const { invitedDm, invitedGroup } = await BootHelpers.getInvitedGroupAndDm(lureMeta); - if (invitedDm && invitedGroup) { + const requiredInvites = + lureMeta?.inviteType === 'user' ? invitedDm : invitedGroup && invitedDm; + + if (requiredInvites) { logger.crumb('confirmed node has the invites'); return NodeBootPhase.ACCEPTING_INVITES; } @@ -218,7 +221,10 @@ export function useBootSequence({ updatedGroup.channels && updatedGroup.channels.length > 0; - if (dmIsGood && groupIsGood) { + const hadSuccess = + lureMeta?.inviteType === 'user' ? dmIsGood : groupIsGood && dmIsGood; + + if (hadSuccess) { logger.crumb('successfully accepted invites'); if (updatedTlonTeamDm) { store.pinChannel(updatedTlonTeamDm); @@ -244,6 +250,7 @@ export function useBootSequence({ lureMeta, reservedNodeId, setShip, + telemetry, ]); // we increment a counter to ensure the effect executes after every run, even if diff --git a/packages/app/hooks/useChannelNavigation.ts b/packages/app/hooks/useChannelNavigation.ts index ca761f2db1..b2708d3941 100644 --- a/packages/app/hooks/useChannelNavigation.ts +++ b/packages/app/hooks/useChannelNavigation.ts @@ -5,6 +5,7 @@ import * as store from '@tloncorp/shared/store'; import { useCallback } from 'react'; import { RootStackParamList } from '../navigation/types'; +import { useRootNavigation } from '../navigation/utils'; export const useChannelNavigation = ({ channelId }: { channelId: string }) => { const channelQuery = store.useChannel({ @@ -16,16 +17,7 @@ export const useChannelNavigation = ({ channelId }: { channelId: string }) => { NativeStackNavigationProp >(); - const navigateToPost = useCallback( - (post: db.Post) => { - navigation.push('Post', { - postId: post.id, - channelId, - authorId: post.authorId, - }); - }, - [channelId, navigation] - ); + const { navigateToPost, navigateToChannel } = useRootNavigation(); const navigateToRef = useCallback( (channel: db.Channel, post: db.Post) => { @@ -35,13 +27,10 @@ export const useChannelNavigation = ({ channelId }: { channelId: string }) => { selectedPostId: post.id, }); } else { - navigation.replace('Channel', { - channelId: channel.id, - selectedPostId: post.id, - }); + navigateToChannel(channel, post.id); } }, - [navigation, channelId] + [navigation, channelId, navigateToChannel] ); const navigateToImage = useCallback( diff --git a/packages/app/hooks/useConfigureUrbitClient.ts b/packages/app/hooks/useConfigureUrbitClient.ts index 71af463f07..017c036924 100644 --- a/packages/app/hooks/useConfigureUrbitClient.ts +++ b/packages/app/hooks/useConfigureUrbitClient.ts @@ -49,11 +49,12 @@ const apiFetch: typeof fetch = (input, { ...init } = {}) => { export function useConfigureUrbitClient() { const shipInfo = useShip(); const { ship, shipUrl, authType } = shipInfo; + const runResetDb = useCallback(() => { + clientLogger.log('Resetting db on logout'); + resetDb(); + }, []); const logout = useHandleLogout({ - resetDb: () => { - clientLogger.log('Resetting db on logout'); - resetDb(); - }, + resetDb: runResetDb, }); return useCallback( diff --git a/packages/app/hooks/useGroupContext.ts b/packages/app/hooks/useGroupContext.ts index 8ea6dc9cb4..ee47a4ce48 100644 --- a/packages/app/hooks/useGroupContext.ts +++ b/packages/app/hooks/useGroupContext.ts @@ -1,4 +1,4 @@ -import { sync, useCreateChannel } from '@tloncorp/shared'; +import { sync } from '@tloncorp/shared'; import * as db from '@tloncorp/shared/db'; import * as store from '@tloncorp/shared/store'; import { useCallback, useEffect, useMemo } from 'react'; diff --git a/packages/app/hooks/useGroupNavigation.ts b/packages/app/hooks/useGroupNavigation.ts index df7597c72f..e73954f82a 100644 --- a/packages/app/hooks/useGroupNavigation.ts +++ b/packages/app/hooks/useGroupNavigation.ts @@ -1,16 +1,17 @@ import { useNavigation } from '@react-navigation/native'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import * as db from '@tloncorp/shared/db'; import { useCallback } from 'react'; import { RootStackParamList } from '../navigation/types'; -import { useResetToGroup } from '../navigation/utils'; +import { useRootNavigation } from '../navigation/utils'; export const useGroupNavigation = () => { - const navigation = useNavigation< - // @ts-expect-error - TODO: pass navigation handlers into context - NativeStackNavigationProp - >(); - const resetToGroup = useResetToGroup(); + const navigation = + useNavigation< + NativeStackNavigationProp + >(); + const { resetToGroup } = useRootNavigation(); const goToChannel = useCallback( async ( @@ -37,17 +38,9 @@ export const useGroupNavigation = () => { navigation.navigate('ChatList'); }, [navigation]); - const goToContactHostedGroups = useCallback( - ({ contactId }: { contactId: string }) => { - navigation.navigate('ContactHostedGroups', { contactId }); - }, - [navigation] - ); - return { goToChannel, goToHome, - goToContactHostedGroups, goToGroup, }; }; diff --git a/packages/app/hooks/useHandleLogout.native.ts b/packages/app/hooks/useHandleLogout.native.ts index 22ace8d1f4..87cb74207c 100644 --- a/packages/app/hooks/useHandleLogout.native.ts +++ b/packages/app/hooks/useHandleLogout.native.ts @@ -5,9 +5,8 @@ import * as store from '@tloncorp/shared/store'; import { useCallback } from 'react'; import { useBranch } from '../contexts/branch'; -import { clearShipInfo, useShip } from '../contexts/ship'; +import { useShip } from '../contexts/ship'; import { removeHostingToken, removeHostingUserId } from '../utils/hosting'; -import { clearSplashDismissed } from '../utils/splash'; import { useClearTelemetryConfig } from './useTelemetry'; const logger = createDevLogger('logout', true); @@ -21,12 +20,10 @@ export function useHandleLogout({ resetDb }: { resetDb: () => void }) { api.queryClient.clear(); store.removeClient(); clearShip(); - clearShipInfo(); removeHostingToken(); removeHostingUserId(); clearLure(); clearDeepLink(); - clearSplashDismissed(); clearTelemetry(); clearNonPersistentStorageItems(); if (!resetDb) { diff --git a/packages/app/hooks/useHandleLogout.ts b/packages/app/hooks/useHandleLogout.ts index 8cb2827909..6f9be30f4b 100644 --- a/packages/app/hooks/useHandleLogout.ts +++ b/packages/app/hooks/useHandleLogout.ts @@ -8,7 +8,7 @@ import { clearNonPersistentStorageItems } from '@tloncorp/shared/db'; import * as store from '@tloncorp/shared/store'; import { useCallback } from 'react'; -import { clearShipInfo, useShip } from '../contexts/ship'; +import { useShip } from '../contexts/ship'; // Can't signup via the webapp, so this is commented out. // We might allow this in a desktop app in the future. // import { useSignupContext } from '../contexts/signup'; @@ -17,7 +17,6 @@ import { removeHostingToken, removeHostingUserId, } from '../utils/hosting'; -import { clearSplashDismissed } from '../utils/splash'; const logger = createDevLogger('logout', true); @@ -29,11 +28,9 @@ export function useHandleLogout({ resetDb }: { resetDb?: () => void }) { api.queryClient.clear(); store.removeClient(); clearShip(); - clearShipInfo(); removeHostingToken(); removeHostingUserId(); removeHostingAuthTracking(); - clearSplashDismissed(); clearNonPersistentStorageItems(); if (!resetDb) { logger.trackError('could not reset db on logout'); diff --git a/packages/app/hooks/usePosthog.base.tsx b/packages/app/hooks/usePosthog.base.tsx new file mode 100644 index 0000000000..b0416abf04 --- /dev/null +++ b/packages/app/hooks/usePosthog.base.tsx @@ -0,0 +1,9 @@ +export interface PosthogClient { + optedOut: boolean; + optIn: () => void; + optOut: () => void; + identify: (userId: string, properties: Record) => void; + capture: (eventName: string, properties?: Record) => void; + flush: () => Promise; + reset: () => void; +} diff --git a/packages/app/hooks/usePosthog.native.ts b/packages/app/hooks/usePosthog.native.ts index 8b18fe20d3..b7698ffd20 100644 --- a/packages/app/hooks/usePosthog.native.ts +++ b/packages/app/hooks/usePosthog.native.ts @@ -1,50 +1,21 @@ import { usePostHog as useNativePosthog } from 'posthog-react-native'; -import { useCallback, useMemo } from 'react'; +import { useMemo } from 'react'; + +import { PosthogClient } from './usePosthog.base'; export function usePosthog() { const posthog = useNativePosthog(); - const optedOut = useMemo(() => { - return posthog?.optedOut ?? false; - }, [posthog]); - - const optIn = useCallback(() => { - return posthog?.optIn(); + return useMemo((): PosthogClient => { + return { + optedOut: posthog?.optedOut ?? false, + optIn: () => posthog?.optIn(), + optOut: () => posthog?.optOut(), + identify: (userId, properties) => posthog?.identify(userId, properties), + capture: (eventName, properties) => + posthog?.capture(eventName, properties), + flush: async () => posthog?.flush(), + reset: () => posthog?.reset(), + }; }, [posthog]); - - const optOut = useCallback(() => { - return posthog?.optOut(); - }, [posthog]); - - const identify = useCallback( - (userId: string, properties: Record) => { - return posthog?.identify(userId, properties); - }, - [posthog] - ); - - const capture = useCallback( - (eventName: string, properties?: Record) => { - return posthog?.capture(eventName, properties); - }, - [posthog] - ); - - const flush = useCallback(async () => { - // TODO: how to send await all pending events sent? - }, []); - - const reset = useCallback(() => { - return posthog?.reset(); - }, [posthog]); - - return { - optedOut, - optIn, - optOut, - identify, - capture, - flush, - reset, - }; } diff --git a/packages/app/hooks/usePosthog.ts b/packages/app/hooks/usePosthog.ts index b482f7a3d6..91068dbc75 100644 --- a/packages/app/hooks/usePosthog.ts +++ b/packages/app/hooks/usePosthog.ts @@ -1,50 +1,22 @@ import { usePostHog as useWebPosthog } from 'posthog-js/react'; -import { useCallback, useMemo } from 'react'; +import { useMemo } from 'react'; + +import { PosthogClient } from './usePosthog.base'; export function usePosthog() { const posthog = useWebPosthog(); - - const optedOut = useMemo(() => { - return posthog?.has_opted_in_capturing() ?? false; - }, [posthog]); - - const optIn = useCallback(() => { - return posthog?.opt_in_capturing(); - }, [posthog]); - - const optOut = useCallback(() => { - return posthog?.opt_out_capturing(); + return useMemo((): PosthogClient => { + return { + optedOut: posthog?.has_opted_out_capturing() ?? false, + optIn: () => posthog?.opt_in_capturing(), + optOut: () => posthog?.opt_out_capturing(), + identify: (userId, properties) => posthog?.identify(userId, properties), + capture: (eventName, properties) => + posthog?.capture(eventName, properties), + flush: async () => { + // TODO: how to send await all pending events sent? + }, + reset: () => posthog?.reset(), + }; }, [posthog]); - - const identify = useCallback( - (userId: string, properties?: Record) => { - return posthog?.identify(userId, properties); - }, - [posthog] - ); - - const capture = useCallback( - (eventName: string, properties?: Record) => { - return posthog?.capture(eventName, properties); - }, - [posthog] - ); - - const flush = useCallback(async () => { - // TODO: how to send await all pending events sent? - }, []); - - const reset = useCallback(() => { - return posthog?.reset(); - }, [posthog]); - - return { - optedOut, - optIn, - optOut, - identify, - capture, - flush, - reset, - }; } diff --git a/packages/app/lib/bootHelpers.ts b/packages/app/lib/bootHelpers.ts index 1331264b6a..3754cd8c81 100644 --- a/packages/app/lib/bootHelpers.ts +++ b/packages/app/lib/bootHelpers.ts @@ -129,9 +129,10 @@ async function getInvitedGroupAndDm(lureMeta: AppInvite | null): Promise<{ if (!lureMeta) { throw new Error('no stored invite found, cannot check'); } - const { invitedGroupId, inviterUserId } = lureMeta; + const { invitedGroupId, inviterUserId, inviteType } = lureMeta; const tlonTeam = `~wittyr-witbes`; - if (!invitedGroupId || !inviterUserId) { + const isPersonalInvite = inviteType === 'user'; + if (!inviterUserId || (!isPersonalInvite && !invitedGroupId)) { throw new Error( `invalid invite metadata: group[${invitedGroupId}] inviter[${inviterUserId}]` ); @@ -139,7 +140,9 @@ async function getInvitedGroupAndDm(lureMeta: AppInvite | null): Promise<{ // use api client to see if you have pending DM and group invite const invitedDm = await db.getChannel({ id: inviterUserId }); const tlonTeamDM = await db.getChannel({ id: tlonTeam }); - const invitedGroup = await db.getGroup({ id: invitedGroupId }); + const invitedGroup = isPersonalInvite + ? null + : await db.getGroup({ id: invitedGroupId! }); return { invitedDm, invitedGroup, tlonTeamDM }; } diff --git a/packages/app/lib/constants.ts b/packages/app/lib/constants.ts new file mode 100644 index 0000000000..2c4d806cf4 --- /dev/null +++ b/packages/app/lib/constants.ts @@ -0,0 +1,14 @@ +import { Platform } from 'react-native'; + +import { ENV_VARS } from '../constants'; + +const TLON_NAMESPACE = 'tlonEnv'; + +// Should be called as early as possible +export function loadConstants(): void { + if (Platform.OS === 'web') { + (window as any)[TLON_NAMESPACE] = ENV_VARS; + } else { + (global as any)[TLON_NAMESPACE] = ENV_VARS; + } +} diff --git a/packages/app/lib/debug.ts b/packages/app/lib/debug.ts deleted file mode 100644 index 15390392fc..0000000000 --- a/packages/app/lib/debug.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { useDebugStore } from '@tloncorp/shared'; - -import storage from './storage'; - -const DEBUG_STORAGE_KEY = 'debug'; - -storage - .load({ - key: DEBUG_STORAGE_KEY, - }) - .then((enabled) => { - useDebugStore.getState().toggle(enabled); - }) - .catch(() => { - useDebugStore.getState().toggle(false); - }); - -export const setDebug = async (enabled: boolean) => { - console.log(`Debug mode ${enabled ? 'enabled' : 'disabled'}`); - useDebugStore.getState().toggle(enabled); - - await storage.save({ - key: DEBUG_STORAGE_KEY, - data: enabled, - }); -}; diff --git a/packages/app/lib/featureFlags.ts b/packages/app/lib/featureFlags.ts index bde2b8875e..0fd5dd6d7f 100644 --- a/packages/app/lib/featureFlags.ts +++ b/packages/app/lib/featureFlags.ts @@ -1,8 +1,7 @@ +import { storage } from '@tloncorp/shared/db'; import { mapValues } from 'lodash'; import create from 'zustand'; -import storage from './storage'; - // Add new feature flags here: export const featureMeta = { channelSwitcher: { @@ -60,11 +59,10 @@ export function useFeatureFlag( return [enabled, setEnabled]; } -const storageKey = 'featureFlags'; async function loadInitialState() { let state: FeatureState | null = null; try { - state = await storage.load({ key: storageKey }); + state = await storage.featureFlags.getValue(); } catch (e) { // ignore } @@ -84,7 +82,7 @@ async function setup() { // Write to local storage on changes, but only after initial load useFeatureFlagStore.subscribe(async (state) => { - await storage.save({ key: storageKey, data: state.flags }); + await storage.featureFlags.setValue(state.flags); }); } setup(); diff --git a/packages/app/lib/notifications.ts b/packages/app/lib/notifications.ts index a3cd7ec8a6..62910df49a 100644 --- a/packages/app/lib/notifications.ts +++ b/packages/app/lib/notifications.ts @@ -91,7 +91,6 @@ const channelIdFromNotification = (notif: Notifications.Notification) => { * We should move to a serverside badge + dismiss notification system, and remove this. */ async function updatePresentedNotifications(badgeCount?: number) { - console.log('updatePresentedNotifications'); const presentedNotifs = await Notifications.getPresentedNotificationsAsync(); const allChannelIds = new Set( compact(presentedNotifs.map(channelIdFromNotification)) diff --git a/packages/app/lib/storage.ts b/packages/app/lib/storage.ts deleted file mode 100644 index 21b963789c..0000000000 --- a/packages/app/lib/storage.ts +++ /dev/null @@ -1,23 +0,0 @@ -import AsyncStorage from '@react-native-async-storage/async-storage'; -import Storage from 'react-native-storage'; - -const storage = new Storage({ - size: 1000, - storageBackend: AsyncStorage, // for web: window.localStorage - - // expire time, default: 1 day (1000 * 3600 * 24 milliseconds). - // can be null, which means never expire. - defaultExpires: null, - - // cache data in the memory. default is true. - enableCache: true, - - // if data was not found in storage or expired data was found, - // the corresponding sync method will be invoked returning - // the latest data. - sync: { - // we'll talk about the details later. - }, -}); - -export default storage; diff --git a/packages/app/navigation/BasePathNavigator.tsx b/packages/app/navigation/BasePathNavigator.tsx index 6722067a38..b5b956d58f 100644 --- a/packages/app/navigation/BasePathNavigator.tsx +++ b/packages/app/navigation/BasePathNavigator.tsx @@ -1,15 +1,20 @@ import { NavigatorScreenParams, + RouteProp, useNavigationState, } from '@react-navigation/native'; import { createNativeStackNavigator } from '@react-navigation/native-stack'; +import { storage } from '@tloncorp/shared/db'; import { useEffect, useMemo, useRef } from 'react'; -import { getLastScreen, setLastScreen } from '../utils/lastScreen'; import { RootStack } from './RootStack'; import { TopLevelDrawer } from './desktop/TopLevelDrawer'; -import { RootDrawerParamList, RootStackParamList } from './types'; -import { useResetToChannel, useTypedReset } from './utils'; +import { + CombinedParamList, + RootDrawerParamList, + RootStackParamList, +} from './types'; +import { useRootNavigation, useTypedReset } from './utils'; export type MobileBasePathStackParamList = { Root: NavigatorScreenParams; @@ -88,17 +93,15 @@ export function BasePathNavigator({ isMobile }: { isMobile: boolean }) { return undefined; }, [isMobile, rootState]); - const resetToChannel = useResetToChannel(); + const { resetToChannel } = useRootNavigation(); const reset = useTypedReset(); useEffect(() => { - const lastScreen = async () => getLastScreen(); - // if we're switching between mobile and desktop, we want to reset the // navigator to the last screen that was open in the other mode if (lastWasMobile.current !== isMobile) { setTimeout(() => { - lastScreen().then((lastScreen) => { + getLastScreen().then((lastScreen) => { if (!lastScreen) { return; } @@ -108,8 +111,8 @@ export function BasePathNavigator({ isMobile }: { isMobile: boolean }) { lastScreen.name === 'GroupDM' || lastScreen.name === 'DM' ) { - resetToChannel(lastScreen.params.channelId, { - groupId: lastScreen.params.groupId, + resetToChannel(lastScreen.params?.channelId, { + groupId: lastScreen.params?.groupId, }); } else if (isMobile && lastScreen.name === 'Home') { reset([{ name: 'ChatList' }]); @@ -130,7 +133,7 @@ export function BasePathNavigator({ isMobile }: { isMobile: boolean }) { useEffect(() => { if (currentScreenAndParams && isMobile === lastWasMobile.current) { - setLastScreen(currentScreenAndParams); + storage.lastScreen.setValue(currentScreenAndParams); } }, [currentScreenAndParams, isMobile]); @@ -143,3 +146,6 @@ export function BasePathNavigator({ isMobile }: { isMobile: boolean }) { ); } + +export const getLastScreen = storage.lastScreen + .getValue as () => Promise>; diff --git a/packages/app/navigation/RootStack.tsx b/packages/app/navigation/RootStack.tsx index 33684ea323..886fe77683 100644 --- a/packages/app/navigation/RootStack.tsx +++ b/packages/app/navigation/RootStack.tsx @@ -20,10 +20,7 @@ import { ActivityScreen } from '../features/top/ActivityScreen'; import ChannelScreen from '../features/top/ChannelScreen'; import ChannelSearchScreen from '../features/top/ChannelSearchScreen'; import ChatListScreen from '../features/top/ChatListScreen'; -import { ContactHostedGroupsScreen } from '../features/top/ContactHostedGroupsScreen'; import ContactsScreen from '../features/top/ContactsScreen'; -import { CreateGroupScreen } from '../features/top/CreateGroupScreen'; -import { FindGroupsScreen } from '../features/top/FindGroupsScreen'; import { GroupChannelsScreen } from '../features/top/GroupChannelsScreen'; import ImageViewerScreen from '../features/top/ImageViewerScreen'; import PostScreen from '../features/top/PostScreen'; @@ -85,12 +82,6 @@ export function RootStack() { {/* individual screens */} - - - diff --git a/packages/app/navigation/desktop/HomeNavigator.tsx b/packages/app/navigation/desktop/HomeNavigator.tsx index 5884d5dba3..4f72cc97ca 100644 --- a/packages/app/navigation/desktop/HomeNavigator.tsx +++ b/packages/app/navigation/desktop/HomeNavigator.tsx @@ -13,9 +13,6 @@ import { EditProfileScreen } from '../../features/settings/EditProfileScreen'; import ChannelScreen from '../../features/top/ChannelScreen'; import ChannelSearchScreen from '../../features/top/ChannelSearchScreen'; import { ChatListScreenView } from '../../features/top/ChatListScreen'; -import { ContactHostedGroupsScreen } from '../../features/top/ContactHostedGroupsScreen'; -import { CreateGroupScreen } from '../../features/top/CreateGroupScreen'; -import { FindGroupsScreen } from '../../features/top/FindGroupsScreen'; import { GroupChannelsScreenContent } from '../../features/top/GroupChannelsScreen'; import ImageViewerScreen from '../../features/top/ImageViewerScreen'; import PostScreen from '../../features/top/PostScreen'; @@ -85,18 +82,6 @@ function MainStack() { initialRouteName="Home" > - - - ); } @@ -106,15 +91,25 @@ const ChannelStackNavigator = createNativeStackNavigator(); function ChannelStack( props: NativeStackScreenProps ) { + const navKey = () => { + if ('channelId' in props.route.params) { + return props.route.params.channelId; + } + if (props.route.params.params && 'channelId' in props.route.params.params) { + return props.route.params.params.channelId; + } + + return 'none'; + }; + return ( - + - + { }; export const TopLevelDrawer = () => { + const { navigateToGroup, navigateToChannel } = useRootNavigation(); + return ( - { - return ; - }} - initialRouteName="Home" - screenOptions={{ - drawerType: 'permanent', - headerShown: false, - drawerStyle: { - width: 48, - backgroundColor: getVariableValue(useTheme().background), - borderRightColor: getVariableValue(useTheme().border), - }, - }} - > - - - - + + + { + return ; + }} + initialRouteName="Home" + screenOptions={{ + drawerType: 'permanent', + headerShown: false, + drawerStyle: { + width: 48, + backgroundColor: getVariableValue(useTheme().background), + borderRightColor: getVariableValue(useTheme().border), + }, + }} + > + + + + + ); }; diff --git a/packages/app/navigation/linking.ts b/packages/app/navigation/linking.ts index 9b09d4e154..879dd2a693 100644 --- a/packages/app/navigation/linking.ts +++ b/packages/app/navigation/linking.ts @@ -26,12 +26,6 @@ export const getMobileLinkingConfig = ( path: 'group/:groupId/channel/:channelId/:selectedPostId?', parse: parsePathParams('channelId', 'groupId', 'selectedPostId'), }, - FindGroups: 'find-groups', - ContactHostedGroups: { - path: 'contacts/:contactId/hosted-groups', - parse: parsePathParams('channelId', 'postId'), - }, - CreateGroup: 'create-group', ChannelSearch: { path: 'channel/:channelId/search' }, Post: postScreenConfig(mode), ImageViewer: 'image-viewer/:postId', @@ -93,12 +87,18 @@ export const getDesktopLinkingConfig = ( ChatList: '', GroupChannels: 'group/:groupId', DM: { - path: 'dm/:channelId/:selectedPostId?', - parse: parsePathParams('channelId', 'selectedPostId'), + path: 'dm/:channelId', + parse: parsePathParams('channelId'), + screens: { + ChannelRoot: '', + }, }, GroupDM: { - path: 'group-dm/:channelId/:selectedPostId?', - parse: parsePathParams('channelId', 'selectedPostId'), + path: 'group-dm/:channelId/', + parse: parsePathParams('channelId'), + screens: { + ChannelRoot: '', + }, }, Channel: { initialRouteName: 'ChannelRoot', @@ -143,8 +143,10 @@ export const getDesktopLinkingConfig = ( }); const postScreenConfig = (mode: string) => ({ - path: basePathForMode(mode) + '/channel/:channelId/post/:authorId/:postId', - parse: parsePathParams('channelId', 'authorId', 'postId'), + path: + basePathForMode(mode) + + '/group/:groupId/channel/:channelId/post/:authorId/:postId', + parse: parsePathParams('groupId', 'channelId', 'authorId', 'postId'), exact: true, }); diff --git a/packages/app/navigation/types.ts b/packages/app/navigation/types.ts index 3b21d50cc7..abd3df8330 100644 --- a/packages/app/navigation/types.ts +++ b/packages/app/navigation/types.ts @@ -25,11 +25,6 @@ export type RootStackParamList = { selectedPostId?: string | null; startDraft?: boolean; }; - FindGroups: undefined; - ContactHostedGroups: { - contactId: string; - }; - CreateGroup: undefined; GroupChannels: { groupId: string; }; @@ -41,6 +36,7 @@ export type RootStackParamList = { postId: string; channelId: string; authorId: string; + groupId?: string; }; ImageViewer: { uri?: string; @@ -78,11 +74,32 @@ export type RootDrawerParamList = { Home: NavigatorScreenParams; } & Pick; +export type CombinedParamList = RootStackParamList & RootDrawerParamList; + export type HomeDrawerParamList = Pick< RootStackParamList, - 'ChatList' | 'GroupChannels' | 'Channel' | 'DM' | 'GroupDM' + 'ChatList' | 'GroupChannels' > & { MainContent: undefined; + Channel: + | NavigatorScreenParams + | RootStackParamList['Channel']; + DM: NavigatorScreenParams | RootStackParamList['DM']; + GroupDM: + | NavigatorScreenParams + | RootStackParamList['GroupDM']; +}; + +export type ChannelStackParamList = { + ChannelRoot: RootStackParamList['Channel']; + GroupSettings: RootStackParamList['GroupSettings']; + ChannelSearch: RootStackParamList['ChannelSearch']; + Post: RootStackParamList['Post']; + ImageViewer: RootStackParamList['ImageViewer']; + UserProfile: RootStackParamList['UserProfile']; + EditProfile: RootStackParamList['EditProfile']; + ChannelMembers: RootStackParamList['ChannelMembers']; + ChannelMeta: RootStackParamList['ChannelMeta']; }; export type DesktopChannelStackParamList = Pick< diff --git a/packages/app/navigation/utils.ts b/packages/app/navigation/utils.ts index a68149f681..ddcbf2fc45 100644 --- a/packages/app/navigation/utils.ts +++ b/packages/app/navigation/utils.ts @@ -1,16 +1,20 @@ import { CommonActions, NavigationProp, - useNavigation, + useNavigation as useReactNavigation, } from '@react-navigation/native'; import * as db from '@tloncorp/shared/db'; import * as logic from '@tloncorp/shared/logic'; import * as store from '@tloncorp/shared/store'; import { useIsWindowNarrow } from '@tloncorp/ui'; -import { useCallback } from 'react'; +import { useCallback, useMemo } from 'react'; import { useFeatureFlagStore } from '../lib/featureFlags'; -import { RootStackNavigationProp, RootStackParamList } from './types'; +import { CombinedParamList } from './types'; + +export const useNavigation = () => { + return useReactNavigation>(); +}; type ResetRouteConfig> = { name: Extract; @@ -38,13 +42,13 @@ export function createTypedReset>( // to the provided routes. It's useful for resetting the navigation stack to a // specific route or set of routes. export function useTypedReset() { - const navigation = useNavigation>(); - + const navigation = useNavigation(); return createTypedReset(navigation); } -export function useResetToChannel() { +function useResetToChannel() { const reset = useTypedReset(); + const isWindowNarrow = useIsWindowNarrow(); return function resetToChannel( channelId: string, @@ -56,20 +60,29 @@ export function useResetToChannel() { ) { const screenName = screenNameFromChannelId(channelId); - reset([ - { name: 'ChatList' }, - { - name: screenName, - params: { - channelId, - ...options, + if (isWindowNarrow) { + reset([ + { name: 'ChatList' }, + { + name: screenName, + params: { + channelId, + ...options, + }, }, - }, - ]); + ]); + } else { + const channelRoute = getDesktopChannelRoute( + channelId, + options?.groupId, + options?.selectedPostId ?? undefined + ); + reset([channelRoute]); + } }; } -export function useResetToDm() { +function useResetToDm() { const resetToChannel = useResetToChannel(); return async function resetToDm(contactId: string) { @@ -84,19 +97,131 @@ export function useResetToDm() { }; } -export function useResetToGroup() { +function useResetToGroup() { const reset = useTypedReset(); + const isWindowNarrow = useIsWindowNarrow(); return async function resetToGroup(groupId: string) { - reset([{ name: 'ChatList' }, await getMainGroupRoute(groupId)]); + if (isWindowNarrow) { + reset([{ name: 'ChatList' }, await getMainGroupRoute(groupId, true)]); + } else { + reset([ + { + name: 'Home', + params: { + screen: 'GroupChannels', + params: { + groupId, + }, + }, + }, + ]); + } }; } -export function useNavigateToGroup() { +function useNavigateToChannel() { const isWindowNarrow = useIsWindowNarrow(); - const navigation = useNavigation(); - const navigationRef = logic.useMutableRef(navigation); + const navigation = useNavigation(); + return useCallback( + (channel: db.Channel, selectedPostId?: string) => { + if (isWindowNarrow) { + const screenName = screenNameFromChannelId(channel.id); + navigation.navigate(screenName, { + channelId: channel.id, + selectedPostId, + ...(channel.groupId ? { groupId: channel.groupId } : {}), + }); + } else { + const channelRoute = getDesktopChannelRoute( + channel.id, + channel.groupId ?? undefined, + selectedPostId + ); + navigation.navigate(channelRoute); + } + }, + [isWindowNarrow, navigation] + ); +} + +export function useNavigateToPost() { + const isWindowNarrow = useIsWindowNarrow(); + const navigation = useNavigation(); + const activityIndex = navigation + .getState() + ?.routes.findIndex((route) => route.name === 'Activity'); + const currentScreenIsActivity = + navigation.getState()?.index === activityIndex; + + return useCallback( + (post: db.Post) => { + if (!isWindowNarrow && currentScreenIsActivity) { + navigation.navigate('Home', { + screen: 'Channel', + params: { + screen: 'Post', + params: { + postId: post.id, + authorId: post.authorId, + channelId: post.channelId, + groupId: post.groupId ?? undefined, + }, + }, + }); + return; + } + + navigation.navigate('Post', { + postId: post.id, + authorId: post.authorId, + channelId: post.channelId, + groupId: post.groupId ?? undefined, + }); + }, + [navigation, isWindowNarrow, currentScreenIsActivity] + ); +} + +export function useNavigateBackFromPost() { + const isWindowNarrow = useIsWindowNarrow(); + const navigation = useNavigation(); + const length = navigation.getState()?.routes.length; + const lastScreenWasActivity = + navigation.getState()?.routes[length - 2]?.name === 'Activity'; + + return useCallback( + (channel: db.Channel, postId: string) => { + if (lastScreenWasActivity) { + navigation.navigate('Activity'); + return; + } + if (isWindowNarrow) { + const screenName = screenNameFromChannelId(channel.id); + navigation.navigate(screenName, { + channelId: channel.id, + selectedPostId: postId, + ...(channel.groupId ? { groupId: channel.groupId } : {}), + }); + } else { + // @ts-expect-error - ChannelRoot is fine here. + navigation.navigate('ChannelRoot', { + channelId: channel.id, + selectedPostId: postId, + groupId: channel.groupId ?? undefined, + }); + } + }, + [navigation, isWindowNarrow, lastScreenWasActivity] + ); +} + +export function useRootNavigation() { + const isWindowNarrow = useIsWindowNarrow(); + const navigation = useNavigation(); + const navigationRef = logic.useMutableRef(navigation); + const navigateToGroup = useCallback( async (groupId: string) => { navigationRef.current.navigate( await getMainGroupRoute(groupId, isWindowNarrow) @@ -104,11 +229,61 @@ export function useNavigateToGroup() { }, [navigationRef, isWindowNarrow] ); + + const resetToChannel = useResetToChannel(); + const navigateToChannel = useNavigateToChannel(); + const navigateBackFromPost = useNavigateBackFromPost(); + const navigateToPost = useNavigateToPost(); + const resetToGroup = useResetToGroup(); + const resetToDm = useResetToDm(); + + return useMemo( + () => ({ + navigation, + navigateToGroup, + navigateToChannel, + navigateBackFromPost, + navigateToPost, + resetToGroup, + resetToChannel, + resetToDm, + }), + [ + navigation, + navigateToChannel, + navigateBackFromPost, + navigateToGroup, + navigateToPost, + resetToGroup, + resetToChannel, + resetToDm, + ] + ); +} + +export function getDesktopChannelRoute( + channelId: string, + groupId?: string, + selectedPostId?: string +) { + const screenName = screenNameFromChannelId(channelId); + return { + name: 'Home', + params: { + screen: screenName, + initial: true, + params: { + channelId, + selectedPostId, + ...(groupId ? { groupId } : {}), + }, + }, + } as const; } export async function getMainGroupRoute( groupId: string, - isWindowNarrow?: boolean + isWindowNarrow: boolean ) { const group = await db.getGroup({ id: groupId }); const channelSwitcherEnabled = @@ -119,10 +294,11 @@ export async function getMainGroupRoute( (group.channels.length === 1 || channelSwitcherEnabled || !isWindowNarrow) ) { if (!isWindowNarrow && group.lastVisitedChannelId) { - return { - name: 'Channel', - params: { channelId: group.lastVisitedChannelId, groupId }, - } as const; + return getDesktopChannelRoute(group.lastVisitedChannelId, groupId); + } + + if (!isWindowNarrow) { + return getDesktopChannelRoute(group.channels[0].id, groupId); } return { diff --git a/packages/app/utils/eula.ts b/packages/app/utils/eula.ts deleted file mode 100644 index 90d4e4d3bb..0000000000 --- a/packages/app/utils/eula.ts +++ /dev/null @@ -1,17 +0,0 @@ -import storage from '../lib/storage'; -import { trackError } from './posthog'; - -export const isEulaAgreed = async () => { - try { - const result = await storage.load({ key: 'eula' }); - return result; - } catch (err) { - if (err instanceof Error && err.name !== 'NotFoundError') { - trackError(err); - } - - return false; - } -}; - -export const setEulaAgreed = () => storage.save({ key: 'eula', data: true }); diff --git a/packages/app/utils/lastScreen.ts b/packages/app/utils/lastScreen.ts deleted file mode 100644 index 770b215524..0000000000 --- a/packages/app/utils/lastScreen.ts +++ /dev/null @@ -1,20 +0,0 @@ -import storage from '../lib/storage'; - -export const setLastScreen = async (screen: { name: string; params: any }) => { - await storage.save({ - key: 'lastScreen', - data: screen, - }); -}; - -export const getLastScreen = async () => { - try { - const result = await storage.load({ key: 'lastScreen' }); - return result; - } catch (err) { - if (err instanceof Error && err.name !== 'NotFoundError') { - console.error(err); - } - return null; - } -}; diff --git a/packages/app/utils/posthog.ts b/packages/app/utils/posthog.ts index 4600a94ef0..4bde65b917 100644 --- a/packages/app/utils/posthog.ts +++ b/packages/app/utils/posthog.ts @@ -12,6 +12,7 @@ export type OnboardingProperties = { phoneNumber?: string; ship?: string; telemetryEnabled?: boolean; + inviteType?: 'personal' | 'group'; }; export let posthog: PostHog | undefined; diff --git a/packages/app/utils/splash.ts b/packages/app/utils/splash.ts deleted file mode 100644 index 683eba9585..0000000000 --- a/packages/app/utils/splash.ts +++ /dev/null @@ -1,21 +0,0 @@ -import storage from '../lib/storage'; -import { trackError } from './posthog'; - -export const isSplashDismissed = async () => { - try { - const result = await storage.load({ key: 'splash' }); - return result; - } catch (err) { - if (err instanceof Error && err.name !== 'NotFoundError') { - trackError(err); - } - - return false; - } -}; - -export const setSplashDismissed = () => - storage.save({ key: 'splash', data: true }); - -export const clearSplashDismissed = () => - storage.save({ key: 'splash', data: false }); diff --git a/packages/shared/src/api/channelsApi.ts b/packages/shared/src/api/channelsApi.ts index bb973fa4c1..6b3ac6d065 100644 --- a/packages/shared/src/api/channelsApi.ts +++ b/packages/shared/src/api/channelsApi.ts @@ -87,7 +87,10 @@ export type ChannelsUpdate = // | MarkChannelReadUpdate | WritersUpdate; -export const createChannel = async (channelPayload: ub.Create) => { +export const createChannel = async ({ + id, + ...channelPayload +}: ub.Create & { id: string }) => { return trackedPoke( { app: 'channels', @@ -98,11 +101,7 @@ export const createChannel = async (channelPayload: ub.Create) => { }, { app: 'channels', path: '/v1' }, (event) => { - return ( - 'create' in event.response && - event.nest === - `${channelPayload.kind}/${getCurrentUserId()}/${channelPayload.name}` - ); + return 'create' in event.response && event.nest === id; } ); }; diff --git a/packages/shared/src/api/groupsApi.ts b/packages/shared/src/api/groupsApi.ts index d25b94cbe5..0503716cd7 100644 --- a/packages/shared/src/api/groupsApi.ts +++ b/packages/shared/src/api/groupsApi.ts @@ -265,12 +265,12 @@ export async function updateGroupPrivacy(params: { }); await poke(cordonSwapAction); } - - const secretAction = groupAction(params.groupId, { - secret: params.newPrivacy === 'secret', - }); - await poke(secretAction); } + + const secretAction = groupAction(params.groupId, { + secret: params.newPrivacy === 'secret', + }); + await poke(secretAction); } export const getPinnedItemType = (rawItem: string) => { @@ -353,27 +353,43 @@ export const findGroupsHostedBy = async (userId: string) => { return toClientGroupsFromPreview(result); }; +const GENERATED_GROUP_TITLE_END_CHAR = '\u2060'; + export const createGroup = async ({ title, - shortCode, + placeholderTitle, + slug, + privacy = 'secret', + memberIds, }: { - title: string; - shortCode: string; + title?: string; + placeholderTitle?: string; + slug: string; + privacy: GroupPrivacy; + memberIds?: string[]; }) => { const createGroupPayload: ub.GroupCreate = { - title, + title: title ? title : placeholderTitle + GENERATED_GROUP_TITLE_END_CHAR, description: '', - image: '#999999', - cover: '#D9D9D9', - name: shortCode, - members: {}, - cordon: { - open: { - ships: [], - ranks: [], - }, - }, - secret: false, + image: '', + cover: '', + name: slug, + members: Object.fromEntries((memberIds ?? []).map((id) => [id, []])), + cordon: + privacy === 'public' + ? { + open: { + ships: [], + ranks: [], + }, + } + : { + shut: { + pending: [], + ask: [], + }, + }, + secret: privacy === 'secret', }; return trackedPoke( @@ -1353,12 +1369,11 @@ export function toClientGroup( id, roles, privacy: extractGroupPrivacy(group), - ...toClientMeta(group.meta), + ...toClientGroupMeta(group.meta), haveInvite: false, currentUserIsMember: isJoined, currentUserIsHost: hostUserId === currentUserId, - // if meta is unset, it's still in the join process - joinStatus: !group.meta || group.meta.title === '' ? 'joining' : undefined, + joinStatus: groupIsSyncing(group) ? 'joining' : undefined, hostUserId, flaggedPosts, navSections: group['zone-ord'] @@ -1400,6 +1415,23 @@ export function toClientGroup( }; } +export function groupIsSyncing(group: ub.Group) { + // if group host is slow, there's a transitional state during group join + // where the group exists on the user's ship, but has not yet synced + // channels, meta, etc. there's no perfect way to handle this, so we attempt + // to use a few different heuristics to detect it. example responses here: + // https://gist.github.com/dnbrwstr/747c3beaa216bc235880c77d55e06448 + return ( + !group['zone-ord'].length && + !group.bloc.length && + group.meta?.title === '' && + group.meta?.description === '' && + group.meta?.cover === '' && + group.meta?.image === '' && + Object.keys(group.channels).length === 0 + ); +} + export function toClientGroupsFromPreview( groups: Record ) { @@ -1450,10 +1482,25 @@ export function toClientGroupFromGang(id: string, gang: ub.Gang): db.Group { haveInvite: !!gang.invite, haveRequestedInvite: gang.claim?.progress === 'knocking', joinStatus, - ...(gang.preview ? toClientMeta(gang.preview.meta) : {}), + ...(gang.preview ? toClientGroupMeta(gang.preview.meta) : {}), + }; +} + +function toClientGroupMeta(meta: ub.GroupMeta) { + return { + ...toClientMeta(meta), + title: toClientGroupTitle(meta.title), }; } +function toClientGroupTitle(title: string) { + if (title.at(-1) === GENERATED_GROUP_TITLE_END_CHAR) { + return ''; + } else { + return title; + } +} + function toClientChannels({ channels, groupId, diff --git a/packages/shared/src/api/index.ts b/packages/shared/src/api/index.ts index 9e51f0b88e..630b4e7cf3 100644 --- a/packages/shared/src/api/index.ts +++ b/packages/shared/src/api/index.ts @@ -1,3 +1,4 @@ +export { udToDate } from './apiUtils'; export * from './channelContentConfig'; export * from './channelsApi'; export * from './chatApi'; @@ -16,3 +17,4 @@ export * from './activityApi'; export * from './harkApi'; export * from './storageApi'; export * from './vitalsApi'; +export * from './inviteApi'; diff --git a/packages/shared/src/api/inviteApi.ts b/packages/shared/src/api/inviteApi.ts new file mode 100644 index 0000000000..f644907891 --- /dev/null +++ b/packages/shared/src/api/inviteApi.ts @@ -0,0 +1,82 @@ +import * as db from '../db'; +import { InviteLinkMetadata } from '../domain/invite.types'; +import { GroupMeta } from '../urbit'; +import { getCurrentUserId, poke, subscribeOnce } from './urbit'; + +const ID_LINK_TIMEOUT = 3 * 1000; + +// Note: Ideally we could avoid "faking" a groupId for these invites, but for now: +// 1. Must be present for %reel to make any sense of it +// 2. Must be flag shaped for %grouper not to crash +const SELF_INVITE_KEY = '~zod/personal-invite-link'; + +function groupsDescribe(meta: GroupMeta & InviteLinkMetadata) { + return { + tag: 'groups-0', + fields: { ...meta }, // makes typescript happy + }; +} + +export async function checkExistingUserInviteLink(): Promise { + try { + const tlonNetworkUrl = await subscribeOnce( + { app: 'reel', path: `/v1/id-link/${SELF_INVITE_KEY}` }, + ID_LINK_TIMEOUT + ); + + if (!tlonNetworkUrl) { + return null; + } + + return tlonNetworkUrl; + } catch (e) { + // expected to throw if it times out. Could harden this handling, but for + // now just assume that means it's not there + return null; + } +} + +export async function createPersonalInviteLink( + currentUser?: db.Contact | null +): Promise { + const currentUserId = getCurrentUserId(); + + // first tell grouper our fake group exists so it can process the bite + // correctly + await poke({ + app: 'grouper', + mark: 'grouper-enable', + json: SELF_INVITE_KEY, + }); + + // then create the invite link entry on the providers + await poke({ + app: 'reel', + mark: 'reel-describe', + json: { + token: SELF_INVITE_KEY, + metadata: groupsDescribe({ + // legacy keys + title: 'Personal Invite', + description: '', + cover: '', + image: '', + + // new-style metadata keys + inviterUserId: currentUserId, + inviterNickname: currentUser?.nickname ?? '', + inviterAvatarImage: currentUser?.avatarImage ?? '', + inviterColor: currentUser?.color ?? '', + inviteType: 'user', + invitedGroupId: '', + }), + }, + }); + + const tlonNetworkUrl = await checkExistingUserInviteLink(); + if (!tlonNetworkUrl) { + throw new Error('Failed to get invite link from reel'); + } + + return tlonNetworkUrl; +} diff --git a/packages/shared/src/api/urbit.ts b/packages/shared/src/api/urbit.ts index b28cf4dbb8..55354109f8 100644 --- a/packages/shared/src/api/urbit.ts +++ b/packages/shared/src/api/urbit.ts @@ -73,7 +73,7 @@ export const client = new Proxy( { get: function (target, prop, receiver) { if (!config.client) { - throw new Error('Database not set.'); + throw new Error('Urbit client not set.'); } return Reflect.get(config.client, prop, receiver); }, diff --git a/packages/shared/src/db/changeListener.ts b/packages/shared/src/db/changeListener.ts index 8b777a5e71..9a63df3ee0 100644 --- a/packages/shared/src/db/changeListener.ts +++ b/packages/shared/src/db/changeListener.ts @@ -29,8 +29,16 @@ export function handleChange({ * keys (`id`, `channel_id`, 'group_id`, etc.) */ row?: any; }) { - if (table === 'posts' && row && !row.parent_id) { + // If a post is updated, we need to refetch the post. If it's a new post, we + // no-op because there's no query to invalidate. + if (table === 'posts' && row && !row.parent_id && operation !== 'INSERT') { postEvents[row.channel_id] ||= []; + if (postEvents[row.channel_id].includes(row.id)) { + // We already have this post in the batch, no need to add it again. + // Without this check, we could end up with duplicate post IDs in the + // batch, which would cause the query to be invalidated multiple times. + return; + } postEvents[row.channel_id].push(row.id); } // We count updates to a post's reaction as post updates so that they trigger diff --git a/packages/shared/src/db/client.ts b/packages/shared/src/db/client.ts index 1f2b69d70e..66ea99a225 100644 --- a/packages/shared/src/db/client.ts +++ b/packages/shared/src/db/client.ts @@ -21,6 +21,22 @@ let clientInstance: AnySqliteDatabase | null = null; export function setClient(client: T) { clientInstance = client; + + if (__DEV__) { + const exec = (strings: TemplateStringsArray, ...values: any[]) => + (client as any).$client.execute(strings.join('?'), values); + const execSimple = (strings: TemplateStringsArray, ...values: any[]) => { + const result = exec(strings, ...values); + return Array(result.rows.length) + .fill(0) + .map((_, i) => result.rows.item(i)); + }; + Object.assign(global, { + __db: client, + __sql: execSimple, + __sqlRaw: exec, + }); + } } export const client = new Proxy( diff --git a/packages/shared/src/db/getStorageMethods.native.ts b/packages/shared/src/db/getStorageMethods.native.ts index 6f1ebd16dd..f8883bc8fe 100644 --- a/packages/shared/src/db/getStorageMethods.native.ts +++ b/packages/shared/src/db/getStorageMethods.native.ts @@ -6,11 +6,13 @@ export function getStorageMethods(isSecure: boolean) { return { getItem: SecureStore.getItemAsync, setItem: SecureStore.setItemAsync, + removeItem: SecureStore.deleteItemAsync, }; } return { getItem: AsyncStorage.getItem, setItem: AsyncStorage.setItem, + removeItem: AsyncStorage.removeItem, }; } diff --git a/packages/shared/src/db/getStorageMethods.ts b/packages/shared/src/db/getStorageMethods.ts index e2b07df3ec..0bb795069c 100644 --- a/packages/shared/src/db/getStorageMethods.ts +++ b/packages/shared/src/db/getStorageMethods.ts @@ -6,11 +6,13 @@ export function getStorageMethods(isSecure: boolean) { return { getItem: AsyncStorage.getItem, setItem: AsyncStorage.setItem, + removeItem: AsyncStorage.removeItem, }; } return { getItem: AsyncStorage.getItem, setItem: AsyncStorage.setItem, + removeItem: AsyncStorage.removeItem, }; } diff --git a/packages/shared/src/db/index.ts b/packages/shared/src/db/index.ts index ce9a2ab5e9..2b18c51b64 100644 --- a/packages/shared/src/db/index.ts +++ b/packages/shared/src/db/index.ts @@ -4,6 +4,7 @@ export * from './types'; export * from './fallback'; export * from './modelBuilders'; export * from './keyValue'; +export * as storage from './keyValue'; export { setClient } from './client'; export type { AnySqliteDatabase } from './client'; export * from './changeListener'; diff --git a/packages/shared/src/db/keyValue.ts b/packages/shared/src/db/keyValue.ts index be89ee3778..cbd0902ccf 100644 --- a/packages/shared/src/db/keyValue.ts +++ b/packages/shared/src/db/keyValue.ts @@ -9,6 +9,7 @@ import { queryClient, } from '../api'; import { createDevLogger } from '../debug'; +import { Lure } from '../logic'; import * as ub from '../urbit'; import { NodeBootPhase, SignupParams } from './domainTypes'; import { getStorageMethods } from './getStorageMethods'; @@ -322,6 +323,16 @@ export const lastAddedSuggestionsAt = createStorageItem({ defaultValue: 0, }); +export const personalInviteLink = createStorageItem({ + key: 'personalInviteLink', + defaultValue: null, +}); + +export const hasViewedPersonalInvite = createStorageItem({ + key: 'hasViewedPersonalInvite', + defaultValue: false, +}); + export const postDraft = (opts: { key: string; type: 'caption' | 'text' | undefined; // matches GalleryDraftType @@ -343,3 +354,43 @@ export const channelSortPreference = createStorageItem({ key: 'channelSortPreference', defaultValue: 'recency', }); + +export const lastScreen = createStorageItem<{ + name: string; + params: any; +} | null>({ + key: 'lastScreen', + defaultValue: null, +}); + +export const invitation = createStorageItem({ + key: 'lure', + defaultValue: null, +}); + +export type ShipInfo = { + authType: 'self' | 'hosted'; + ship: string | undefined; + shipUrl: string | undefined; + authCookie: string | undefined; +}; + +export const shipInfo = createStorageItem({ + key: 'store', + defaultValue: null, +}); + +export const featureFlags = createStorageItem({ + key: 'featureFlags', + defaultValue: null, +}); + +export const eulaAgreed = createStorageItem({ + key: 'eula', + defaultValue: false, +}); + +export const splashDismissed = createStorageItem({ + key: 'splash', + defaultValue: false, +}); diff --git a/packages/shared/src/db/migrations/0000_loving_namora.sql b/packages/shared/src/db/migrations/0000_safe_zarda.sql similarity index 99% rename from packages/shared/src/db/migrations/0000_loving_namora.sql rename to packages/shared/src/db/migrations/0000_safe_zarda.sql index 6953459b7d..d03677e2c2 100644 --- a/packages/shared/src/db/migrations/0000_loving_namora.sql +++ b/packages/shared/src/db/migrations/0000_safe_zarda.sql @@ -294,7 +294,7 @@ CREATE TABLE `posts` ( `last_edit_content` text, `last_edit_title` text, `last_edit_image` text, - `synced_at` integer NOT NULL, + `synced_at` integer, `backend_time` text ); --> statement-breakpoint diff --git a/packages/shared/src/db/migrations/meta/0000_snapshot.json b/packages/shared/src/db/migrations/meta/0000_snapshot.json index f63b6445e7..53f2353d76 100644 --- a/packages/shared/src/db/migrations/meta/0000_snapshot.json +++ b/packages/shared/src/db/migrations/meta/0000_snapshot.json @@ -1,7 +1,7 @@ { "version": "6", "dialect": "sqlite", - "id": "74260723-19aa-401e-92d0-c790bcc15a53", + "id": "faa3b44e-123d-4ff7-88c8-5e2a6fefa25b", "prevId": "00000000-0000-0000-0000-000000000000", "tables": { "activity_event_contact_group_pins": { @@ -1961,7 +1961,7 @@ "name": "synced_at", "type": "integer", "primaryKey": false, - "notNull": true, + "notNull": false, "autoincrement": false }, "backend_time": { diff --git a/packages/shared/src/db/migrations/meta/_journal.json b/packages/shared/src/db/migrations/meta/_journal.json index ee7b772857..de1433b419 100644 --- a/packages/shared/src/db/migrations/meta/_journal.json +++ b/packages/shared/src/db/migrations/meta/_journal.json @@ -5,8 +5,8 @@ { "idx": 0, "version": "6", - "when": 1733348853492, - "tag": "0000_loving_namora", + "when": 1733961238109, + "tag": "0000_safe_zarda", "breakpoints": true } ] diff --git a/packages/shared/src/db/migrations/migrations.js b/packages/shared/src/db/migrations/migrations.js index 94bde2c6a2..285afb5e6d 100644 --- a/packages/shared/src/db/migrations/migrations.js +++ b/packages/shared/src/db/migrations/migrations.js @@ -1,7 +1,7 @@ // This file is required for Expo/React Native SQLite migrations - https://orm.drizzle.team/quick-sqlite/expo import journal from './meta/_journal.json'; -import m0000 from './0000_loving_namora.sql'; +import m0000 from './0000_safe_zarda.sql'; export default { journal, diff --git a/packages/shared/src/db/modelBuilders.ts b/packages/shared/src/db/modelBuilders.ts index 050ce84764..cdaea9fa1f 100644 --- a/packages/shared/src/db/modelBuilders.ts +++ b/packages/shared/src/db/modelBuilders.ts @@ -1,11 +1,7 @@ import { unixToDa } from '@urbit/aura'; import * as api from '../api'; -import { - getCanonicalPostId, - isDmChannelId, - isGroupDmChannelId, -} from '../api/apiUtils'; +import { getCanonicalPostId } from '../api/apiUtils'; import * as db from '../db'; import * as logic from '../logic'; import { convertToAscii } from '../logic'; @@ -29,7 +25,6 @@ export function assembleNewChannelIdAndName({ const tempChannelName = titleIsNumber ? `channel-${title}` : convertToAscii(title).replace(/[^a-z]*([a-z][-\w\d]+)/i, '$1'); - // @ts-expect-error this is fine const channelKind = getChannelKindFromType(channelType); const tempNewChannelFlag = `${channelKind}/${currentUserId}/${tempChannelName}`; const existingChannel = () => { @@ -151,13 +146,6 @@ export function buildPendingPost({ parentId, }); - // TODO: punt on DM delivery status until we have a single subscription - // to lean on - const deliveryStatus = - isDmChannelId(channel.id) || isGroupDmChannelId(channel.id) - ? null - : 'pending'; - return { id, author, @@ -178,7 +166,7 @@ export function buildPendingPost({ replyCount: 0, hidden: false, parentId, - deliveryStatus, + deliveryStatus: 'pending', syncedAt: Date.now(), ...postFlags, }; @@ -260,7 +248,6 @@ export function buildChannel( | 'groupId' | 'iconImage' | 'iconImageColor' - | 'isDefaultWelcomeChannel' | 'isDmInvite' | 'isPendingChannel' | 'lastPostAt' @@ -286,7 +273,6 @@ export function buildChannel( groupId: null, iconImage: null, iconImageColor: null, - isDefaultWelcomeChannel: null, isDmInvite: false, isPendingChannel: null, lastPostAt: null, @@ -356,3 +342,42 @@ export function buildChatMembers( }, }; } + +export function postFromPostActivityEvent(post: ub.PostEvent['post']): db.Post { + const id = post.key.id.split('/')[1]; + const receivedAt = getReceivedAtFromId(id); + const { sent, author } = ub.getIdParts(post.key.id); + return { + id, + authorId: author, + channelId: post.channel, + content: post.content, + type: 'chat', + receivedAt, + syncedAt: undefined, + sentAt: sent, + }; +} + +export function postFromDmPostActivityEvent( + dmPost: ub.DmPostEvent['dm-post'] +): db.Post { + const { sent, author } = ub.getIdParts(dmPost.key.id); + // key.id looks like `dm/000.000.000.mor.eme.ssa.gei.dxx` + const id = dmPost.key.id.split('/')[1]; + const receivedAt = getReceivedAtFromId(id); + return { + id, + authorId: author, + channelId: (dmPost.whom as { ship: string }).ship!, + content: dmPost.content, + type: 'chat', + receivedAt, + syncedAt: undefined, + sentAt: sent, + }; +} + +function getReceivedAtFromId(postId: string) { + return api.udToDate(postId.split('/').pop() ?? postId); +} diff --git a/packages/shared/src/db/queries.ts b/packages/shared/src/db/queries.ts index 37c443cf40..8e904f091e 100644 --- a/packages/shared/src/db/queries.ts +++ b/packages/shared/src/db/queries.ts @@ -110,6 +110,13 @@ const GROUP_META_COLUMNS = { coverImageColor: true, }; +const POST_RELATIONS_DEFAULT = { + author: true, + reactions: true, + threadUnread: true, + volumeSettings: true, +} as const; + export interface GetGroupsOptions { includeUnjoined?: boolean; includeUnreads?: boolean; @@ -1494,31 +1501,6 @@ export const getChannelWithRelations = createReadQuery( ['channels', 'volumeSettings', 'pins', 'groups', 'contacts', 'channelUnreads'] ); -export const getStaleChannels = createReadQuery( - 'getStaleChannels', - async (ctx: QueryCtx) => { - return ctx.db - .select({ - ...getTableColumns($channels), - unread: getTableColumns($channelUnreads), - }) - .from($channels) - .innerJoin($channelUnreads, eq($channelUnreads.channelId, $channels.id)) - .where( - or( - isNull($channels.lastPostAt), - lt($channels.remoteUpdatedAt, $channelUnreads.updatedAt) - ) - ) - .leftJoin( - $pins, - or(eq($pins.itemId, $channels.id), eq($pins.itemId, $channels.groupId)) - ) - .orderBy(ascNullsLast($pins.index), desc($channelUnreads.updatedAt)); - }, - ['channels'] -); - export const insertChannels = createWriteQuery( 'insertChannels', async (channels: Channel[], ctx: QueryCtx) => { @@ -1867,6 +1849,32 @@ export const setLeftGroups = createWriteQuery( ['groups', 'channels'] ); +// Includes latest post as well as unconfirmed posts +export const getUnconfirmedPosts = createReadQuery( + 'getUnconfirmedPosts', + async ({ channelId }: { channelId: string }, ctx) => { + const lastPostResults = await ctx.db + .select({ lastPostId: $channels.lastPostId }) + .from($channels) + .where(eq($channels.id, channelId)); + const lastPostId = + lastPostResults.length === 0 ? null : lastPostResults[0].lastPostId; + return ctx.db.query.posts.findMany({ + where: or( + and( + eq($posts.channelId, channelId), + isNull($posts.syncedAt), + not(eq($posts.type, 'reply')) + ), + lastPostId == null ? undefined : eq($posts.id, lastPostId) + ), + orderBy: asc($posts.id), + with: POST_RELATIONS_DEFAULT, + }); + }, + ['posts', 'channels'] +); + export type GetChannelPostsOptions = { channelId: string; count?: number; @@ -1880,6 +1888,34 @@ export const getChannelPosts = createReadQuery( { channelId, cursor, mode, count = 50 }: GetChannelPostsOptions, ctx: QueryCtx ): Promise => { + /** We'll only fetch posts in the window containing this post. + * + * Why not just use `cursor`? Because `cursor` may be an unconfirmed post, + * which will not be in an explicit window, and thus we'd only show the + * single post until more posts loaded. + * In that case, we want to move to the closest confirmed window. */ + const windowPost = await (async () => { + if (cursor == null) { + return null; + } + const cursorPost = await ctx.db.query.posts.findFirst({ + where: eq($posts.id, cursor), + }); + if (cursorPost == null || cursorPost.syncedAt != null) { + return { id: cursor }; + } + + // `cursorPost` is unconfirmed; its window won't have many (any) more posts. + // Use the next less-recent confirmed post instead. + return await ctx.db.query.posts.findFirst({ + where: and( + eq($posts.channelId, channelId), + lte($posts.id, cursor), + isNotNull($posts.syncedAt) // i.e. post is confirmed + ), + }); + })(); + // Find the window (set of contiguous posts) that this cursor belongs to. // These are the posts that we can return safely without gaps and without hitting the api. const window = await ctx.db.query.postWindows.findFirst({ @@ -1888,8 +1924,8 @@ export const getChannelPosts = createReadQuery( eq($postWindows.channelId, channelId), // Depending on mode, either older or newer than cursor. If mode is // `newest`, we don't need to filter by cursor. - cursor ? gte($postWindows.newestPostId, cursor) : undefined, - cursor ? lte($postWindows.oldestPostId, cursor) : undefined + windowPost ? gte($postWindows.newestPostId, windowPost.id) : undefined, + windowPost ? lte($postWindows.oldestPostId, windowPost.id) : undefined ), orderBy: [desc($postWindows.newestPostId)], columns: { @@ -1902,12 +1938,7 @@ export const getChannelPosts = createReadQuery( return []; } - const relationConfig = { - author: true, - reactions: true, - threadUnread: true, - volumeSettings: true, - } as const; + const isPostConfirmed = isNotNull($posts.syncedAt); if (mode === 'newer' || mode === 'newest' || mode === 'older') { // Simple case: just grab a set of posts from either side of the cursor. @@ -1923,9 +1954,10 @@ export const getChannelPosts = createReadQuery( // Depending on mode, either older or newer than cursor. If mode is // `newest`, we don't need to filter by cursor. cursor && mode === 'older' ? lt($posts.id, cursor) : undefined, - cursor && mode === 'newer' ? gt($posts.id, cursor) : undefined + cursor && mode === 'newer' ? gt($posts.id, cursor) : undefined, + isPostConfirmed ), - with: relationConfig, + with: POST_RELATIONS_DEFAULT, // If newer, we have to ensure that these are the newer posts directly following the cursor orderBy: [mode === 'newer' ? asc($posts.id) : desc($posts.id)], limit: count, @@ -1962,7 +1994,8 @@ export const getChannelPosts = createReadQuery( eq($posts.channelId, channelId), not(eq($posts.type, 'reply')), gte($posts.id, window.oldestPostId), - lte($posts.id, window.newestPostId) + lte($posts.id, window.newestPostId), + isPostConfirmed ) ) .as('posts'); @@ -2002,11 +2035,12 @@ export const getChannelPosts = createReadQuery( .where( and( gte($windowQuery.rowNumber, startRow), - lte($windowQuery.rowNumber, endRow) + lte($windowQuery.rowNumber, endRow), + isPostConfirmed ) ) ), - with: relationConfig, + with: POST_RELATIONS_DEFAULT, orderBy: [desc($posts.id)], limit: count, }); @@ -2151,7 +2185,61 @@ export const insertLatestPosts = createWriteQuery( ['posts'] ); +export const insertUnconfirmedPosts = createWriteQuery( + 'insertUnconfirmedPosts', + async ({ posts }: { posts: Post[] }, ctx: QueryCtx) => { + if (!posts.length) { + return; + } + if (posts.some((p) => p.syncedAt != null)) { + throw new Error('insertUnconfirmedPosts: posts should not have syncedAt'); + } + return withTransactionCtx(ctx, async (txCtx) => { + // insertPosts does multiple queries internally, so we need to wrap it in a transaction + await insertPosts(posts, txCtx); + }); + }, + ['posts'] +); + async function insertPosts(posts: Post[], ctx: QueryCtx) { + // HACK: I can't get onConflictDoUpdate to work - manually manage conflicts. + // Likely https://github.com/drizzle-team/drizzle-orm/issues/2276 + await (async () => { + const existing = await ctx.db.query.posts.findMany({ + where: inArray( + $posts.id, + posts.map((p) => p.id) + ), + }); + if (existing.length === 0) { + return; + } + const replace: typeof posts = []; + for (const x of existing) { + // Skip insert if we already have a confirmed post and the insert is unconfirmed + // (iow: we want to update unconfirmed posts with confirmed or + // unconfirmed updates; we want to update confirmed posts only with + // confirmed updates) + if (x.syncedAt != null) { + const toInsertIdx = posts.findIndex((p) => p.id === x.id); + if (toInsertIdx !== -1 && posts[toInsertIdx].syncedAt == null) { + posts.splice(toInsertIdx, 1); + } + } else { + replace.push(x); + } + } + + // Manually delete existing posts that we'll replace. + await ctx.db.delete($posts).where( + inArray( + $posts.id, + replace.map((p) => p.id) + ) + ); + })(); + await ctx.db .insert($posts) .values( @@ -2628,7 +2716,13 @@ export const getPostWithRelations = createReadQuery( export const getGroup = createReadQuery( 'getGroup', - async ({ id }: { id: string }, ctx: QueryCtx) => { + async ( + { + id, + includeUnjoinedChannels = false, + }: { id: string; includeUnjoinedChannels?: boolean }, + ctx: QueryCtx + ) => { return ctx.db.query.groups .findFirst({ where: (groups, { eq }) => eq(groups.id, id), @@ -2636,7 +2730,9 @@ export const getGroup = createReadQuery( unread: true, pin: true, channels: { - where: (channels, { eq }) => eq(channels.currentUserIsMember, true), + where: includeUnjoinedChannels + ? undefined + : (channels, { eq }) => eq(channels.currentUserIsMember, true), with: { lastPost: true, unread: true, @@ -3099,6 +3195,16 @@ export const insertThreadUnreads = createWriteQuery( ['threadUnreads', 'channelUnreads'] ); +export const getThreadUnreadsByChannel = createReadQuery( + 'getThreadUnreadsByChannel', + async ({ channelId }: { channelId: string }, ctx: QueryCtx) => { + return ctx.db.query.threadUnreads.findMany({ + where: eq($threadUnreads.channelId, channelId), + }); + }, + ['threadUnreads'] +); + export const clearThreadUnread = createWriteQuery( 'clearThreadUnread', async ( diff --git a/packages/shared/src/db/schema.ts b/packages/shared/src/db/schema.ts index f7c8550134..b3888745ae 100644 --- a/packages/shared/src/db/schema.ts +++ b/packages/shared/src/db/schema.ts @@ -752,11 +752,6 @@ export const channels = sqliteTable( */ lastViewedAt: timestamp('last_viewed_at'), - /** - * True if this channel was autocreated during new group creation (on this client) - */ - isDefaultWelcomeChannel: boolean('is_default_welcome_channel'), - contentConfiguration: text('content_configuration', { mode: 'json', }).$type(), @@ -835,7 +830,10 @@ export const posts = sqliteTable( lastEditContent: text('last_edit_content', { mode: 'json' }), lastEditTitle: text('last_edit_title'), lastEditImage: text('last_edit_image'), - syncedAt: timestamp('synced_at').notNull(), + /** + * If `syncedAt` is null, it indicates that the post is unconfirmed by sync. + */ + syncedAt: timestamp('synced_at'), // backendTime translates to an unfortunate alternative timestamp that is used // in some places by the backend agents as part of a composite key for identifying a post. // You should not be accessing this field except in very particular contexts. diff --git a/packages/shared/src/debug.ts b/packages/shared/src/debug.ts index f58d3a0ad5..7a7f3d2b96 100644 --- a/packages/shared/src/debug.ts +++ b/packages/shared/src/debug.ts @@ -1,7 +1,9 @@ import { useEffect, useRef } from 'react'; import { v4 as uuidv4 } from 'uuid'; import create from 'zustand'; +import { persist } from 'zustand/middleware'; +import { getStorageMethods } from './db/getStorageMethods'; import { useLiveRef } from './logic/utilHooks'; import { useCurrentSession } from './store/session'; @@ -68,92 +70,104 @@ interface DebugStore { initializeErrorLogger: (errorLoggerInput: ErrorLoggerStub) => void; } -export const useDebugStore = create((set, get) => ({ - enabled: false, - customLoggers: new Set(), - errorLogger: null, - debugBreadcrumbs: [], - logs: [], - logId: null, - platform: null, - appInfo: null, - toggle: (enabled) => { - set(() => ({ - enabled, - })); - }, - appendLog: (log: Log) => { - set((state) => ({ - logs: [...state.logs, log], - })); - }, - uploadLogs: async () => { - const { logs, errorLogger, platform, appInfo, debugBreadcrumbs } = get(); - const platformInfo = await platform?.getDebugInfo(); - const debugInfo = { - ...appInfo, - ...platformInfo, - }; - const infoSize = roughMeasure(debugInfo); - const crumbSize = roughMeasure(debugBreadcrumbs); - const mappedLogs = logs.map((log) => log.message); - const runSize = MAX_POSTHOG_EVENT_SIZE - crumbSize - infoSize; - const runs = splitLogs(mappedLogs, runSize); - const logId = uuidv4(); - - for (let i = 0; i < runs.length; i++) { - errorLogger?.capture('debug_logs', { - logId, - page: `Page ${i + 1} of ${runs.length}`, - logs: runs[i], - breadcrumbs: debugBreadcrumbs, - debugInfo, - }); - } - - set(() => ({ +export const useDebugStore = create( + persist( + (set, get) => ({ + enabled: false, + customLoggers: new Set(), + errorLogger: null, + debugBreadcrumbs: [], logs: [], - logId, - })); - - return logId; - }, - addBreadcrumb: (crumb: Breadcrumb) => { - set((state) => { - const debugBreadcrumbs = state.debugBreadcrumbs.slice(); - debugBreadcrumbs.push(crumb); - - if (debugBreadcrumbs.length >= BREADCRUMB_LIMIT) { - debugBreadcrumbs.shift(); - } + logId: null, + platform: null, + appInfo: null, + toggle: (enabled) => { + set({ + enabled, + }); + }, + appendLog: (log: Log) => { + set((state) => ({ + logs: [...state.logs, log], + })); + }, + uploadLogs: async () => { + const { logs, errorLogger, platform, appInfo, debugBreadcrumbs } = + get(); + const platformInfo = await platform?.getDebugInfo(); + const debugInfo = { + ...appInfo, + ...platformInfo, + }; + const infoSize = roughMeasure(debugInfo); + const crumbSize = roughMeasure(debugBreadcrumbs); + const mappedLogs = logs.map((log) => log.message); + const runSize = MAX_POSTHOG_EVENT_SIZE - crumbSize - infoSize; + const runs = splitLogs(mappedLogs, runSize); + const logId = uuidv4(); + + for (let i = 0; i < runs.length; i++) { + errorLogger?.capture('debug_logs', { + logId, + page: `Page ${i + 1} of ${runs.length}`, + logs: runs[i], + breadcrumbs: debugBreadcrumbs, + debugInfo, + }); + } - return state; - }); - }, - getBreadcrumbs: () => { - const { debugBreadcrumbs } = get(); - const includeSensitiveContext = true; // TODO: handle accordingly - return debugBreadcrumbs.map((crumb) => { - return `[${crumb.tag}] ${crumb.message ?? ''}${includeSensitiveContext && crumb.sensitive ? crumb.sensitive : ''}`; - }); - }, - addCustomEnabledLoggers: (loggers) => { - set((state) => { - loggers.forEach((logger) => state.customLoggers.add(logger)); - return state; - }); - }, - initializeDebugInfo: (platform, appInfo) => { - set(() => ({ - platform, - })); - }, - initializeErrorLogger: (errorLoggerInput) => { - set(() => ({ - errorLogger: errorLoggerInput, - })); - }, -})); + set(() => ({ + logs: [], + logId, + })); + + return logId; + }, + addBreadcrumb: (crumb: Breadcrumb) => { + set((state) => { + const debugBreadcrumbs = state.debugBreadcrumbs.slice(); + debugBreadcrumbs.push(crumb); + + if (debugBreadcrumbs.length >= BREADCRUMB_LIMIT) { + debugBreadcrumbs.shift(); + } + + return state; + }); + }, + getBreadcrumbs: () => { + const { debugBreadcrumbs } = get(); + const includeSensitiveContext = true; // TODO: handle accordingly + return debugBreadcrumbs.map((crumb) => { + return `[${crumb.tag}] ${crumb.message ?? ''}${includeSensitiveContext && crumb.sensitive ? crumb.sensitive : ''}`; + }); + }, + addCustomEnabledLoggers: (loggers) => { + set((state) => { + loggers.forEach((logger) => state.customLoggers.add(logger)); + return state; + }); + }, + initializeDebugInfo: (platform, appInfo) => { + set(() => ({ + platform, + })); + }, + initializeErrorLogger: (errorLoggerInput) => { + set(() => ({ + errorLogger: errorLoggerInput, + })); + }, + }), + { + name: 'debug-store', + getStorage: () => getStorageMethods(false), + partialize: (state) => { + return { enabled: state.enabled }; + }, + } + ) +); export function addCustomEnabledLoggers(loggers: string[]) { return useDebugStore.getState().addCustomEnabledLoggers(loggers); diff --git a/packages/shared/src/domain/constants.ts b/packages/shared/src/domain/constants.ts new file mode 100644 index 0000000000..64b22ade64 --- /dev/null +++ b/packages/shared/src/domain/constants.ts @@ -0,0 +1,42 @@ +const TLON_NAMESPACE = 'tlonEnv'; + +interface Constants { + NOTIFY_PROVIDER: string; + NOTIFY_SERVICE: string; + POST_HOG_API_KEY: string; + API_URL: string; + API_AUTH_USERNAME: string | undefined; + API_AUTH_PASSWORD: string | undefined; + RECAPTCHA_SITE_KEY: string; + SHIP_URL_PATTERN: string; + DEFAULT_LURE: string; + DEFAULT_PRIORITY_TOKEN: string; + DEFAULT_TLON_LOGIN_EMAIL: string; + DEFAULT_TLON_LOGIN_PASSWORD: string; + DEFAULT_INVITE_LINK_URL: string; + DEFAULT_SHIP_LOGIN_URL: string; + DEFAULT_SHIP_LOGIN_ACCESS_CODE: string; + DEFAULT_ONBOARDING_PASSWORD: string; + DEFAULT_ONBOARDING_TLON_EMAIL: string; + DEFAULT_ONBOARDING_NICKNAME: string; + DEFAULT_ONBOARDING_PHONE_NUMBER: string | undefined; + ENABLED_LOGGERS: string[]; + IGNORE_COSMOS: boolean; + TLON_EMPLOYEE_GROUP: string; + BRANCH_KEY: string; + BRANCH_DOMAIN: string; + INVITE_SERVICE_ENDPOINT: string; + INVITE_SERVICE_IS_DEV: boolean; +} + +export function getConstants(): Constants { + if ( + window && + (window as any)[TLON_NAMESPACE] && + typeof (window as any)[TLON_NAMESPACE] === 'object' + ) { + return (window as any)[TLON_NAMESPACE] as Constants; + } + + return (global as any)[TLON_NAMESPACE] as Constants; +} diff --git a/packages/shared/src/domain/index.ts b/packages/shared/src/domain/index.ts new file mode 100644 index 0000000000..286e2be121 --- /dev/null +++ b/packages/shared/src/domain/index.ts @@ -0,0 +1,2 @@ +export * from './constants'; +export * from './invite.types'; diff --git a/packages/shared/src/domain/invite.types.ts b/packages/shared/src/domain/invite.types.ts new file mode 100644 index 0000000000..7c052cbb51 --- /dev/null +++ b/packages/shared/src/domain/invite.types.ts @@ -0,0 +1,19 @@ +export interface InviteLinkMetadata { + $og_title?: string; + $og_description?: string; + $og_image_url?: string; + $twitter_title?: string; + $twitter_description?: string; + $twitter_image_url?: string; + $twitter_card?: string; + inviterUserId?: string; + inviterNickname?: string; + inviterAvatarImage?: string; + inviterColor?: string; + invitedGroupId?: string; + invitedGroupTitle?: string; + invitedGroupDescription?: string; + invitedGroupIconImageUrl?: string; + invitedGroupiconImageColor?: string; + inviteType?: 'user' | 'group'; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 386beb74fb..783ee83e96 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -28,6 +28,7 @@ export { export { parseActiveTab, trimFullPath } from './logic/navigation'; export * from './logic'; export * from './store'; +export * from './domain'; export * as sync from './store/sync'; export * as utils from './logic/utils'; export * as tiptap from './logic/tiptap'; diff --git a/packages/shared/src/logic/analytics.ts b/packages/shared/src/logic/analytics.ts index 99b69b6810..99a4a32066 100644 --- a/packages/shared/src/logic/analytics.ts +++ b/packages/shared/src/logic/analytics.ts @@ -10,5 +10,8 @@ export enum AnalyticsEvent { ContactAdded = 'Contact Added', ContactEdited = 'Contact Edited', InvitedUserFailedInventoryCheck = 'Invited User Failed Inventory Check', + PersonalInvitePressed = 'Personal Invite Shown', ChannelTemplateSetup = 'Channel Created from Template', + ChannelLoadComplete = 'Channel Load Complete', + SessionInitialized = 'Session Initialized', } diff --git a/packages/shared/src/logic/branch.ts b/packages/shared/src/logic/branch.ts index fe07bf9857..1683b5752a 100644 --- a/packages/shared/src/logic/branch.ts +++ b/packages/shared/src/logic/branch.ts @@ -2,6 +2,7 @@ import { isValidPatp } from '@urbit/aura'; import { getPostInfoFromWer } from '../api/harkApi'; import { createDevLogger } from '../debug'; +import { getConstants } from '../domain'; const logger = createDevLogger('branch', true); @@ -71,11 +72,13 @@ export interface DeepLinkMetadata { inviterUserId?: string; inviterNickname?: string; inviterAvatarImage?: string; + inviterColor?: string; invitedGroupId?: string; invitedGroupTitle?: string; invitedGroupDescription?: string; invitedGroupIconImageUrl?: string; invitedGroupiconImageColor?: string; + inviteType?: 'user' | 'group'; } export interface AppInvite extends DeepLinkMetadata { @@ -104,11 +107,13 @@ export function extractLureMetadata(branchParams: any) { inviterUserId: branchParams.inviterUserId, inviterNickname: branchParams.inviterNickname, inviterAvatarImage: branchParams.inviterAvatarImage, + inviterColor: branchParams.inviterColor, invitedGroupId: branchParams.invitedGroupId, invitedGroupTitle: branchParams.invitedGroupTitle, invitedGroupDescription: branchParams.invitedGroupDescription, invitedGroupIconImageUrl: branchParams.invitedGroupIconImageUrl, invitedGroupiconImageColor: branchParams.invitedGroupiconImageColor, + inviteType: branchParams.inviteType, }; } @@ -133,17 +138,15 @@ export const createDeepLink = async ({ fallbackUrl, type, path, - inviteServiceEndpoint, - inviteServiceIsDev, metadata, }: { fallbackUrl: string | undefined; type: DeepLinkType; path: string; - inviteServiceEndpoint: string; - inviteServiceIsDev: boolean; metadata?: DeepLinkMetadata; }) => { + const env = getConstants(); + if (!fallbackUrl || !path) { return undefined; } @@ -174,10 +177,8 @@ export const createDeepLink = async ({ try { const inviteLink = await getLinkFromInviteService({ - alias, + inviteId: alias, data, - inviteServiceEndpoint, - inviteServiceIsDev, }); return inviteLink; } catch (e) { @@ -189,30 +190,27 @@ export const createDeepLink = async ({ }; async function getLinkFromInviteService({ - alias, + inviteId, data, - inviteServiceEndpoint, - inviteServiceIsDev, }: { - alias: string; + inviteId: string; data: DeepLinkData; - inviteServiceEndpoint: string; - inviteServiceIsDev: boolean; }): Promise { - const response = await fetch(inviteServiceEndpoint, { + const env = getConstants(); + const response = await fetch(env.INVITE_SERVICE_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ - inviteId: alias, + inviteId, data: data, - testEnv: inviteServiceIsDev, + testEnv: env.INVITE_SERVICE_IS_DEV, }), }); if (!response.ok) { throw new Error( - `Failed to get invite link from service [${response.status}]: ${alias}` + `Failed to get invite link from service [${response.status}]: ${inviteId}` ); } @@ -223,3 +221,21 @@ async function getLinkFromInviteService({ return inviteLink; } + +export async function checkInviteServiceLinkExists(inviteId: string) { + const env = getConstants(); + // hack to avoid shuffling env vars around + const serverlessInfraUrl = env.INVITE_SERVICE_ENDPOINT.substring( + 0, + env.INVITE_SERVICE_ENDPOINT.lastIndexOf('/') + ); + const response = await fetch(`${serverlessInfraUrl}/checkLink`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ inviteId }), + }); + + return response.status === 200; +} diff --git a/packages/shared/src/logic/deeplinks.ts b/packages/shared/src/logic/deeplinks.ts index 03984de345..0cc4513ebc 100644 --- a/packages/shared/src/logic/deeplinks.ts +++ b/packages/shared/src/logic/deeplinks.ts @@ -1,11 +1,7 @@ import { ContentReference } from '../api'; +import { getConstants } from '../domain'; import { citeToPath } from '../urbit'; -import { - AppInvite, - DeepLinkMetadata, - getBranchLinkMeta, - isLureMeta, -} from './branch'; +import { AppInvite, getBranchLinkMeta, isLureMeta } from './branch'; export async function getReferenceFromDeeplink({ deepLink, @@ -23,6 +19,7 @@ export async function getReferenceFromDeeplink({ }); if (linkMeta && typeof linkMeta === 'object') { + // TODO: handle personal invite links if (isLureMeta(linkMeta) && linkMeta.invitedGroupId) { return { reference: { @@ -48,6 +45,8 @@ interface ProviderMetadataResponse { inviter?: string; inviterNickname?: string; inviterAvatarImage?: string; + inviterColor?: string; + inviteType?: 'user' | 'group'; }; } @@ -92,6 +91,8 @@ export async function getInviteLinkMeta({ invitedGroupIconImageUrl: responseMeta.fields.image, inviterNickname: responseMeta.fields.inviterNickname, inviterAvatarImage: responseMeta.fields.inviterAvatarImage, + inviterColor: responseMeta.fields.inviterColor, + inviteType: responseMeta.fields.inviteType, }; // some links might not have everything, try to extend with branch (fine if fails) @@ -122,10 +123,13 @@ export function createInviteLinkRegex(branchDomain: string) { export function extractTokenFromInviteLink( url: string, - branchDomain: string + branchDomain?: string ): string | null { + const env = getConstants(); if (!url) return null; - const INVITE_LINK_REGEX = createInviteLinkRegex(branchDomain); + const INVITE_LINK_REGEX = createInviteLinkRegex( + branchDomain ?? env.BRANCH_DOMAIN + ); const match = url.trim().match(INVITE_LINK_REGEX); if (match) { @@ -137,19 +141,17 @@ export function extractTokenFromInviteLink( return null; } -export function extractNormalizedInviteLink( - url: string, - branchDomain: string -): string | null { +export function extractNormalizedInviteLink(url: string): string | null { if (!url) return null; - const INVITE_LINK_REGEX = createInviteLinkRegex(branchDomain); + const env = getConstants(); + const INVITE_LINK_REGEX = createInviteLinkRegex(env.BRANCH_DOMAIN); const match = url.trim().match(INVITE_LINK_REGEX); if (match) { const parts = match[0].split('/'); const token = parts[parts.length - 1]; if (token) { - return `https://${branchDomain}/${token}`; + return `https://${env.BRANCH_DOMAIN}/${token}`; } } diff --git a/packages/shared/src/logic/utils.ts b/packages/shared/src/logic/utils.ts index 2d44a104f2..b39a16360a 100644 --- a/packages/shared/src/logic/utils.ts +++ b/packages/shared/src/logic/utils.ts @@ -1,3 +1,4 @@ +import { formatUv } from '@urbit/aura'; import anyAscii from 'any-ascii'; import { differenceInDays, endOfToday, format } from 'date-fns'; import emojiRegex from 'emoji-regex'; @@ -506,10 +507,20 @@ export interface RetryConfig { numOfAttempts?: number; } -export const withRetry = (fn: () => Promise, config?: RetryConfig) => { +export const withRetry = (fn: () => Promise, config?: RetryConfig) => { return backOff(fn, { delayFirstAttempt: false, startingDelay: config?.startingDelay ?? 1000, numOfAttempts: config?.numOfAttempts ?? 4, }); }; + +/** + * Random id value for group or channel, 4 bits of entropy, eg 0v2a.lmibb + */ +export function getRandomId() { + return formatUv(Math.floor(Math.random() * 0xffffffff).toString()).replace( + /[^a-z]*([a-z][-\w\d]+)/gi, + '$1' + ); +} diff --git a/packages/shared/src/store/channelActions.ts b/packages/shared/src/store/channelActions.ts index 8ce7752b40..23026e0245 100644 --- a/packages/shared/src/store/channelActions.ts +++ b/packages/shared/src/store/channelActions.ts @@ -5,40 +5,42 @@ import { } from '../api/channelContentConfig'; import * as db from '../db'; import { createDevLogger } from '../debug'; -import * as logic from '../logic'; +import { getRandomId } from '../logic'; import { GroupChannel, getChannelKindFromType } from '../urbit'; const logger = createDevLogger('ChannelActions', false); export async function createChannel({ groupId, - channelId, - name, title, // Alias to `rawDescription`, since we might need to synthesize a new // `description` API value by merging with `contentConfiguration` below. description: rawDescription, - channelType, + channelType: rawChannelType, contentConfiguration, }: { groupId: string; - channelId: string; - name: string; title: string; description?: string; - channelType: Omit; + channelType: Omit | 'custom'; contentConfiguration?: ChannelContentConfiguration; }) { + const currentUserId = api.getCurrentUserId(); + const channelType = rawChannelType === 'custom' ? 'chat' : rawChannelType; + const channelSlug = getRandomId(); + const channelId = `${getChannelKindFromType(channelType)}/${currentUserId}/${channelSlug}`; // optimistic update const newChannel: db.Channel = { id: channelId, title, description: rawDescription, - contentConfiguration, type: channelType as db.ChannelType, groupId, addedToGroupAt: Date.now(), currentUserIsMember: true, + contentConfiguration: + contentConfiguration ?? + channelContentConfigurationForChannelType(channelType), }; await db.insertChannels([newChannel]); @@ -54,12 +56,11 @@ export async function createChannel({ }); try { - await api.addChannelToGroup({ groupId, channelId, sectionId: 'default' }); await api.createChannel({ - // @ts-expect-error this is fine + id: channelId, kind: getChannelKindFromType(channelType), group: groupId, - name, + name: channelSlug, title, description: encodedDescription ?? '', readers: [], @@ -71,6 +72,41 @@ export async function createChannel({ // rollback optimistic update await db.deleteChannel(channelId); } + + return newChannel; +} + +/** + * Creates a `ChannelContentConfiguration` matching our built-in legacy + * channel types. With this configuration in place, we can treat these channels + * as we would any other custom channel, and avoid switching on `channel.type` + * in client code. + */ +function channelContentConfigurationForChannelType( + channelType: Omit +): ChannelContentConfiguration { + switch (channelType) { + case 'chat': + return { + draftInput: api.DraftInputId.chat, + defaultPostContentRenderer: api.PostContentRendererId.chat, + defaultPostCollectionRenderer: api.CollectionRendererId.chat, + }; + case 'notebook': + return { + draftInput: api.DraftInputId.notebook, + defaultPostContentRenderer: api.PostContentRendererId.notebook, + defaultPostCollectionRenderer: api.CollectionRendererId.notebook, + }; + case 'gallery': + return { + draftInput: api.DraftInputId.gallery, + defaultPostContentRenderer: api.PostContentRendererId.gallery, + defaultPostCollectionRenderer: api.CollectionRendererId.gallery, + }; + } + + throw new Error('Unknown channel type'); } export async function deleteChannel({ @@ -192,13 +228,8 @@ export async function unpinItem(pin: db.Pin) { } } -export async function markChannelVisited(channel: db.Channel) { - const now = Date.now(); - logger.log( - `marking channel as visited (${channel.lastViewedAt} -> ${now})`, - channel.id - ); - await db.updateChannel({ id: channel.id, lastViewedAt: now }); +export async function markChannelVisited(channelId: string) { + await db.updateChannel({ id: channelId, lastViewedAt: Date.now() }); } export type MarkChannelReadParams = Pick; diff --git a/packages/shared/src/store/dbHooks.ts b/packages/shared/src/store/dbHooks.ts index 3ceee7aec2..1b41a405aa 100644 --- a/packages/shared/src/store/dbHooks.ts +++ b/packages/shared/src/store/dbHooks.ts @@ -325,6 +325,16 @@ export const useGroup = ({ id }: { id?: string }) => { }); }; +export const useGroupUnread = ({ groupId }: { groupId: string }) => { + return useQuery({ + queryKey: [ + ['groupUnread', { groupId: groupId }], + useKeyFromQueryDeps(db.getGroupUnread, { groupId: groupId }), + ], + queryFn: async () => db.getGroupUnread({ groupId: groupId }), + }); +}; + export const useJoinedGroupsCount = () => { const deps = useKeyFromQueryDeps(db.getJoinedGroupsCount); return useQuery({ diff --git a/packages/shared/src/store/groupActions.ts b/packages/shared/src/store/groupActions.ts index a9da961c8c..71286bf889 100644 --- a/packages/shared/src/store/groupActions.ts +++ b/packages/shared/src/store/groupActions.ts @@ -2,54 +2,72 @@ import * as api from '../api'; import * as db from '../db'; import { GroupPrivacy } from '../db/schema'; import { createDevLogger } from '../debug'; +import { getRandomId } from '../logic'; import { createSectionId } from '../urbit'; -import * as sync from './sync'; +import { createChannel } from './channelActions'; -const logger = createDevLogger('groupActions', false); +const logger = createDevLogger('groupActions', true); -export async function createGroup({ - title, - shortCode, -}: { - title: string; - shortCode: string; -}): Promise<{ group: db.Group; channel: db.Channel }> { - logger.log(`${shortCode}: creating group`); +interface CreateGroupParams { + title?: string; + memberIds?: string[]; +} + +export async function createGroup( + params: CreateGroupParams +): Promise { const currentUserId = api.getCurrentUserId(); + const groupSlug = getRandomId(); + const groupId = `${currentUserId}/${groupSlug}`; + try { + logger.log('creating group', groupId); + await api.createGroup({ - title, - shortCode, + title: params.title ?? '', + placeholderTitle: await getPlaceholderTitle(params), + slug: groupSlug, + privacy: 'secret', + memberIds: params.memberIds, }); - const groupId = `${currentUserId}/${shortCode}`; + logger.log(`created group ${groupId}, adding default channel`); - await api.createNewGroupDefaultChannel({ - groupId, - currentUserId, + await createChannel({ + groupId: groupId, + title: 'General', + channelType: 'chat', }); - logger.log(`created group ${groupId}`); - - await sync.syncGroup(groupId); - await sync.syncUnreads(); // ensure current user gets registered as a member of the channel - const group = await db.getGroup({ id: groupId }); - - if (group && group.channels.length) { - const channel = group.channels[0]; - await db.updateChannel({ id: channel.id, isDefaultWelcomeChannel: true }); + logger.log(`created default channel for ${groupId}, syncing now`); - return { group, channel }; + const group = await db.getGroup({ id: groupId }); + if (!group || !group.channels.length) { + throw new Error('Something went wrong'); } - // TODO: should we have a UserFacingError type? - throw new Error('Something went wrong'); + return group; } catch (e) { - console.error(`${shortCode}: failed to create group`, e); + console.error(`${groupSlug}: failed to create group`, e); throw new Error('Something went wrong'); } } +async function getPlaceholderTitle({ memberIds, title }: CreateGroupParams) { + // No need to set a placeholder title if the user has already set a title + if (title) { + return; + } + const currentUserId = api.getCurrentUserId(); + const contactIds = [...(memberIds ?? []), currentUserId]; + const memberContacts = await Promise.all( + contactIds.map( + async (id): Promise => (await db.getContact({ id })) ?? { id } + ) + ); + return memberContacts.map((c) => c?.nickname ?? c?.id).join(', '); +} + export async function acceptGroupInvitation(group: db.Group) { logger.log('accepting group invitation', group.id); await db.updateGroup({ id: group.id, joinStatus: 'joining' }); @@ -180,9 +198,9 @@ export async function markGroupNew(group: db.Group) { await db.updateGroup({ id: group.id, isNew: true }); } -export async function markGroupVisited(group: db.Group) { - logger.log('marking new group as visited', group.id); - await db.updateGroup({ id: group.id, isNew: false }); +export async function markGroupVisited(groupId: string) { + logger.log('marking new group as visited', groupId); + await db.updateGroup({ id: groupId, isNew: false }); } export async function updateGroupPrivacy( diff --git a/packages/shared/src/store/index.ts b/packages/shared/src/store/index.ts index fde487955f..cad7a94240 100644 --- a/packages/shared/src/store/index.ts +++ b/packages/shared/src/store/index.ts @@ -4,7 +4,6 @@ export * from './sync'; export * from './useChannelPosts'; export * from './useChannelSearch'; export * from './useChannelHooksPreview'; -export * from './useCreateChannel'; export * from './postActions'; export * from './channelActions'; export * from './groupActions'; @@ -18,4 +17,5 @@ export * from './session'; export * from './contactActions'; export * from './clientActions'; export * from './lure'; +export * from './inviteActions'; export { useChannelContext } from './useChannelContext'; diff --git a/packages/shared/src/store/inviteActions.ts b/packages/shared/src/store/inviteActions.ts new file mode 100644 index 0000000000..620c44d70e --- /dev/null +++ b/packages/shared/src/store/inviteActions.ts @@ -0,0 +1,97 @@ +import { getCurrentUserId } from '../api'; +import * as api from '../api'; +import * as db from '../db'; +import { createDevLogger } from '../debug'; +import { + checkInviteServiceLinkExists, + createDeepLink, + extractNormalizedInviteLink, + extractTokenFromInviteLink, + withRetry, +} from '../logic'; + +const logger = createDevLogger('inviteActions', true); + +export async function verifyUserInviteLink() { + try { + const cachedInviteLink = await db.personalInviteLink.getValue(); + if (cachedInviteLink) { + console.log('have cached invite link', cachedInviteLink); + return; + } + + let finalInviteLink = ''; + const inviteLink = await api.checkExistingUserInviteLink(); + + if (!inviteLink) { + // if we don't have one on the provider, create it + finalInviteLink = await withRetry(() => createUserInviteLink()); + logger.trackEvent('Created personal invite link'); + } else { + // otherwise, make sure we have the corresponding link on the invite service + const inviteId = extractTokenFromInviteLink(inviteLink); + if (!inviteId) { + throw new Error(`Provider returned invalid link ${inviteLink}`); + } + const serviceInviteLinkExists = + await checkInviteServiceLinkExists(inviteId); + if (!serviceInviteLinkExists) { + await withRetry(() => createPersonalInviteLinkOnService(inviteLink!)); + } + finalInviteLink = extractNormalizedInviteLink(inviteLink) ?? ''; + } + + if (finalInviteLink) { + await db.personalInviteLink.setValue(finalInviteLink); + } + } catch (e) { + logger.trackError('Failed to verify personal invite link', { + errorMessage: e.message, + errorStack: e.stack, + }); + } +} + +export async function createUserInviteLink(): Promise { + const currentUserId = getCurrentUserId(); + const user = await db.getContact({ id: currentUserId }); + + const tlonNetworkUrl = await api.createPersonalInviteLink(user); + const inviteLink = await createPersonalInviteLinkOnService(tlonNetworkUrl); + return inviteLink; +} + +export async function createPersonalInviteLinkOnService( + tlonNetworkUrl: string +) { + const currentUserId = getCurrentUserId(); + const user = await db.getContact({ id: currentUserId }); + if (!tlonNetworkUrl) { + throw new Error( + 'upsertExistingPersonalInviteDeeplink: Failed to get invite link from reel' + ); + } + + // create on branch + const inviteLink = await createDeepLink({ + fallbackUrl: tlonNetworkUrl, + type: 'lure', + path: 'stub', // doesn't matter in this case + metadata: { + inviterUserId: currentUserId, + inviterNickname: user?.nickname ?? '', + inviterAvatarImage: user?.avatarImage ?? '', + inviterColor: user?.color ?? '', + inviteType: 'user', + invitedGroupId: '', + }, + }); + + if (!inviteLink) { + throw new Error( + 'upsertExistingPersonalInviteDeeplink: Failed to wrap self invite deep link' + ); + } + + return inviteLink; +} diff --git a/packages/shared/src/store/lure.ts b/packages/shared/src/store/lure.ts index 017d33f4b9..701981b001 100644 --- a/packages/shared/src/store/lure.ts +++ b/packages/shared/src/store/lure.ts @@ -7,10 +7,12 @@ import { getCurrentUserId, poke, scry, subscribeOnce } from '../api/urbit'; import * as db from '../db'; import { createDevLogger } from '../debug'; import { DeepLinkMetadata, createDeepLink } from '../logic/branch'; -import { asyncWithDefault, getFlagParts } from '../logic/utils'; +import { asyncWithDefault, getFlagParts, withRetry } from '../logic/utils'; import { stringToTa } from '../urbit'; import { GroupMeta } from '../urbit/groups'; +const logger = createDevLogger('lure', true); + interface LureMetadata { tag: string; fields: Record; @@ -220,8 +222,6 @@ export const useLureState = create((set, get) => ({ fallbackUrl: url, type: 'lure', path: flag, - inviteServiceEndpoint, - inviteServiceIsDev, metadata, }); lureLogger.crumb('deepLinkUrl created', deepLinkUrl); diff --git a/packages/shared/src/store/sync.ts b/packages/shared/src/store/sync.ts index e3546cf02d..5e69346a50 100644 --- a/packages/shared/src/store/sync.ts +++ b/packages/shared/src/store/sync.ts @@ -7,12 +7,13 @@ import { GetChangedPostsOptions } from '../api'; import * as db from '../db'; import { QueryCtx, batchEffects } from '../db/query'; import { createDevLogger, runIfDev } from '../debug'; +import { AnalyticsEvent } from '../logic'; import { extractClientVolumes } from '../logic/activity'; import { INFINITE_ACTIVITY_QUERY_KEY, resetActivityFetchers, } from '../store/useActivityFetchers'; -import { findContactSuggestions } from './contactActions'; +import { verifyUserInviteLink } from './inviteActions'; import { useLureState } from './lure'; import { getSyncing, updateIsSyncing, updateSession } from './session'; import { SyncCtx, SyncPriority, syncQueue } from './syncQueue'; @@ -262,7 +263,27 @@ export const syncChannelThreadUnreads = async ( const unreads = await syncQueue.add('thread unreads', ctx, () => api.getThreadUnreadsByChannel(channel) ); - await db.insertThreadUnreads(unreads); + const existingUnreads = await db.getThreadUnreadsByChannel({ channelId }); + + // filter out any unreads that we already have in the db so we can avoid + // invalidating queries that don't need to be invalidated + const newUnreads = unreads.filter((unread) => { + const existing = existingUnreads.find( + (u) => u.threadId === unread.threadId + ); + + if (!existing) { + return true; + } + + return !_.isEqual(unread, existing); + }); + + if (newUnreads.length === 0) { + return; + } + + await db.insertThreadUnreads(newUnreads); }; export async function syncPostReference(options: { @@ -299,6 +320,10 @@ export async function syncUpdatedPosts( posts: response.posts, }); + await db.deletePosts({ + ids: response.deletedPosts ?? [], + }); + return response; } @@ -1092,6 +1117,7 @@ export const syncStart = async (alreadySubscribed?: boolean) => { // we probably don't want multiple sync starts return; } + const startTime = Date.now(); updateIsSyncing(true); logger.crumb(`sync start running${alreadySubscribed ? ' (recovery)' : ''}`); @@ -1134,6 +1160,9 @@ export const syncStart = async (alreadySubscribed?: boolean) => { logger.crumb('finished initializing high priority subs'); logger.crumb(`finished high priority init sync`); + logger.trackEvent(AnalyticsEvent.SessionInitialized, { + duration: Date.now() - startTime, + }); updateSession({ startTime: Date.now() }); }); @@ -1175,6 +1204,9 @@ export const syncStart = async (alreadySubscribed?: boolean) => { }); updateIsSyncing(false); + + // post sync initialization work + verifyUserInviteLink(); }; export const setupHighPrioritySubscriptions = async (ctx?: SyncCtx) => { diff --git a/packages/shared/src/store/useChannelPosts.ts b/packages/shared/src/store/useChannelPosts.ts index 16b309d039..3e4f54d943 100644 --- a/packages/shared/src/store/useChannelPosts.ts +++ b/packages/shared/src/store/useChannelPosts.ts @@ -5,8 +5,10 @@ import { } from '@tanstack/react-query'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { getChannelIdType } from '../api/apiUtils'; import * as db from '../db'; import { createDevLogger } from '../debug'; +import { AnalyticsEvent } from '../logic'; import { useDebouncedValue, useLiveRef, @@ -32,7 +34,7 @@ type UseChannelPostsPageParams = db.GetChannelPostsOptions; type PostQueryData = InfiniteData; type SubscriptionPost = [db.Post, string | undefined]; -type UseChanelPostsParams = UseChannelPostsPageParams & { +type UseChannelPostsParams = UseChannelPostsPageParams & { enabled: boolean; firstPageCount?: number; hasCachedNewest?: boolean; @@ -42,7 +44,7 @@ export const clearChannelPostsQueries = () => { queryClient.invalidateQueries({ queryKey: ['channelPosts'] }); }; -export const useChannelPosts = (options: UseChanelPostsParams) => { +export const useChannelPosts = (options: UseChannelPostsParams) => { const mountTime = useMemo(() => { return Date.now(); }, []); @@ -63,7 +65,7 @@ export const useChannelPosts = (options: UseChanelPostsParams) => { refetchOnMount: false, queryFn: async (ctx): Promise => { const queryOptions = ctx.pageParam || options; - postsLogger.log('loading posts', queryOptions); + postsLogger.log('loading posts', { queryOptions, options }); // We should figure out why this is necessary. if ( queryOptions && @@ -86,7 +88,7 @@ export const useChannelPosts = (options: UseChanelPostsParams) => { }, { priority: SyncPriority.High } ); - postsLogger.log('loaded', res.posts?.length, 'posts from api'); + postsLogger.log('loaded', res.posts?.length, 'posts from api', { res }); const secondResult = await db.getChannelPosts(queryOptions); postsLogger.log( 'returning', @@ -127,12 +129,18 @@ export const useChannelPosts = (options: UseChanelPostsParams) => { }, getPreviousPageParam: ( firstPage, - _allPages, + allPages, _firstPageParam ): UseChannelPostsPageParams | undefined => { - if (!firstPage.canFetchNewerPosts) { + // if any page has reached the newest post, we can't fetch any newer posts + // page order for allPages should be newest -> oldest + // but apparently on channels with less than 50 posts, the order is reversed (on web only) + const hasReachedNewest = allPages.some((p) => !p.canFetchNewerPosts); + + if (hasReachedNewest) { return undefined; } + return { ...options, mode: 'newer', @@ -154,28 +162,74 @@ export const useChannelPosts = (options: UseChanelPostsParams) => { ); useSubscriptionPostListener(handleNewPost); + // Why store the unconfirmed posts in a separate state? + // With a live query, we'd see duplicates between the latest post from + // getUnconfirmedPosts and latest post from the main useChannelPosts query. + // (This *shouldn't* be the case - we should be deduplicating - but I think + // there's a timing issue here that is taking too long to debug.) + const [unconfirmedPosts, setUnconfirmedPosts] = useState( + null + ); + useEffect(() => { + db.getUnconfirmedPosts({ channelId: options.channelId }).then( + setUnconfirmedPosts + ); + }, [options.channelId]); const rawPosts = useMemo(() => { - const queryPosts = query.data?.pages.flatMap((p) => p.posts) ?? null; - if (!newPosts.length || query.hasPreviousPage) { - return queryPosts; + const rawPostsWithoutUnconfirmeds = (() => { + const queryPosts = query.data?.pages.flatMap((p) => p.posts) ?? null; + if (!newPosts.length || query.hasPreviousPage) { + return queryPosts; + } + const newestQueryPostId = queryPosts?.[0]?.id; + const newerPosts = newPosts.filter( + (p) => !newestQueryPostId || p.id > newestQueryPostId + ); + // Deduping is necessary because the query data may not have been updated + // at this point and we may have already added the post. + // This is most likely to happen in bad network conditions or when the + // ship is under heavy load. + // This seems to be caused by an async issue where clearMatchedPendingPosts + // is called before the new post is added to the query data. + // TODO: Figure out why this is happening. + const dedupedQueryPosts = + queryPosts?.filter( + (p) => !newerPosts.some((newer) => newer.sentAt === p.sentAt) + ) ?? []; + return newestQueryPostId + ? [...newerPosts, ...dedupedQueryPosts] + : newPosts; + })(); + + if (unconfirmedPosts == null) { + return rawPostsWithoutUnconfirmeds; } - const newestQueryPostId = queryPosts?.[0]?.id; - const newerPosts = newPosts.filter( - (p) => !newestQueryPostId || p.id > newestQueryPostId - ); - // Deduping is necessary because the query data may not have been updated - // at this point and we may have already added the post. - // This is most likely to happen in bad network conditions or when the - // ship is under heavy load. - // This seems to be caused by an async issue where clearMatchedPendingPosts - // is called before the new post is added to the query data. - // TODO: Figure out why this is happening. - const dedupedQueryPosts = - queryPosts?.filter( - (p) => !newerPosts.some((newer) => newer.sentAt === p.sentAt) - ) ?? []; - return newestQueryPostId ? [...newerPosts, ...dedupedQueryPosts] : newPosts; - }, [query.data, query.hasPreviousPage, newPosts]); + + // Then, add "unconfirmed" posts (which we have received through e.g. push + // notifications but haven't confirmed via sync). Skip if we already have a + // confirmed version of the post. + // + // Why not dedupe these alongside `newPosts`? We hold off on showing + // `newPosts` until we've fully backfilled the channel + // (`!query.hasPreviousPage`) - but we want to show unconfirmeds ASAP. + const out = rawPostsWithoutUnconfirmeds ?? []; + // bubble-insert unconfirmed posts + for (const p of unconfirmedPosts ?? []) { + // skip if we already have this post + if (out.some((qp) => qp.id === p.id)) { + continue; + } + + const insertIdx = out.findIndex((x) => x.sentAt <= p.sentAt); + if (insertIdx === -1) { + out.push(p); + } else { + out.splice(insertIdx, 0, p); + } + } + + return out; + }, [query.data, query.hasPreviousPage, newPosts, unconfirmedPosts]); const posts = useOptimizedQueryResults(rawPosts); @@ -192,12 +246,49 @@ export const useChannelPosts = (options: UseChanelPostsParams) => { const { loadOlder, loadNewer } = useLoadActionsWithPendingHandlers(query); + useTrackReady(posts, query, options.channelId); + return useMemo( () => ({ posts, query, loadOlder, loadNewer, isLoading }), [posts, query, loadOlder, loadNewer, isLoading] ); }; +/** + * Send a posthog event once we either have ~enough posts to fill the screen, or + * we've loaded all posts. + */ +function useTrackReady( + posts: db.Post[] | null, + query: UseInfiniteQueryResult, Error>, + channelId: string +) { + const startTimeRef = useRef(Date.now()); + const loadTracked = useRef(false); + const alreadyTracked = loadTracked.current; + const postsLength = posts?.length ?? 0; + const hasEnoughPosts = postsLength > 30; + const isLoading = query.isLoading || query.isPending; + const canLoadMore = query.hasNextPage || query.hasPreviousPage; + + useEffect(() => { + if (!alreadyTracked && (hasEnoughPosts || (!isLoading && !canLoadMore))) { + loadTracked.current = true; + postsLogger.trackEvent(AnalyticsEvent.ChannelLoadComplete, { + channelType: getChannelIdType(channelId), + duration: Date.now() - startTimeRef.current, + }); + } + }, [ + alreadyTracked, + canLoadMore, + channelId, + hasEnoughPosts, + isLoading, + postsLength, + ]); +} + /** * Insert a post into our working posts array, merging + resorting if necessary. */ @@ -244,8 +335,9 @@ function useRefreshPosts(channelId: string, posts: db.Post[] | null) { const toSync = posts?.filter( (post) => - session && - post.syncedAt < (session?.startTime ?? 0) && + // consider unconfirmed posts as stale + (post.syncedAt == null || + (session && post.syncedAt < (session?.startTime ?? 0))) && !pendingStalePosts.current.has(post.id) ) || []; diff --git a/packages/shared/src/store/useCreateChannel.ts b/packages/shared/src/store/useCreateChannel.ts deleted file mode 100644 index ce826d7032..0000000000 --- a/packages/shared/src/store/useCreateChannel.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { useCallback } from 'react'; - -import { - ChannelContentConfiguration, - CollectionRendererId, - DraftInputId, - PostContentRendererId, -} from '../api/channelContentConfig'; -import { assembleNewChannelIdAndName } from '../db/modelBuilders'; -import * as db from '../db/types'; -import { createChannel } from '../store/channelActions'; -import { useAllChannels } from '../store/dbHooks'; - -export function useCreateChannel({ - group, - currentUserId, - disabled = false, -}: { - group?: db.Group | null; - currentUserId: string; - disabled?: boolean; -}) { - const { data: existingChannels } = useAllChannels({ - enabled: !disabled, - }); - - return useCallback( - async ({ - title, - description, - channelType, - contentConfiguration, - }: { - title: string; - description?: string; - channelType: Omit; - contentConfiguration?: ChannelContentConfiguration; - }) => { - if (!group) { - return; - } - - const { name, id } = assembleNewChannelIdAndName({ - title, - channelType, - existingChannels: existingChannels ?? [], - currentUserId, - }); - - return createChannel({ - groupId: group.id, - name, - channelId: id, - title, - description, - channelType, - contentConfiguration: - contentConfiguration ?? - channelContentConfigurationForChannelType(channelType), - }); - }, - [group, currentUserId, existingChannels] - ); -} - -/** - * Creates a `ChannelContentConfiguration` matching our built-in legacy - * channel types. With this configuration in place, we can treat these channels - * as we would any other custom channel, and avoid switching on `channel.type` - * in client code. - */ -function channelContentConfigurationForChannelType( - channelType: Omit -): ChannelContentConfiguration { - switch (channelType) { - case 'chat': - return { - draftInput: DraftInputId.chat, - defaultPostContentRenderer: PostContentRendererId.chat, - defaultPostCollectionRenderer: CollectionRendererId.chat, - }; - case 'notebook': - return { - draftInput: DraftInputId.notebook, - defaultPostContentRenderer: PostContentRendererId.notebook, - defaultPostCollectionRenderer: CollectionRendererId.notebook, - }; - case 'gallery': - return { - draftInput: DraftInputId.gallery, - defaultPostContentRenderer: PostContentRendererId.gallery, - defaultPostCollectionRenderer: CollectionRendererId.gallery, - }; - } - - throw new Error('Unknown channel type'); -} diff --git a/packages/shared/src/urbit/activity.ts b/packages/shared/src/urbit/activity.ts index 5fb92a690e..bea2a0745f 100644 --- a/packages/shared/src/urbit/activity.ts +++ b/packages/shared/src/urbit/activity.ts @@ -1,4 +1,4 @@ -import { parseUd } from '@urbit/aura'; +import { daToUnix, parseUd } from '@urbit/aura'; import _ from 'lodash'; import { Kind, Story } from './channel'; @@ -760,7 +760,7 @@ export function getIdParts(id: string): { author: string; sent: number } { const [author, sentStr] = id.split('/'); return { author, - sent: parseInt(parseUd(sentStr).toString(), 10), + sent: daToUnix(parseUd(sentStr)), }; } diff --git a/packages/shared/src/urbit/utils.ts b/packages/shared/src/urbit/utils.ts index 4cbdb36fed..a38e5da194 100644 --- a/packages/shared/src/urbit/utils.ts +++ b/packages/shared/src/urbit/utils.ts @@ -3,6 +3,7 @@ import bigInt from 'big-integer'; import { useMemo } from 'react'; import { ContentReference, PostContent } from '../api'; +import { ChannelType } from '../db'; import { GroupJoinStatus, GroupPrivacy } from '../db/schema'; import * as ub from './channel'; import * as ubc from './content'; @@ -196,7 +197,9 @@ export function getChannelType(channelId: string) { } } -export function getChannelKindFromType(type: 'chat' | 'gallery' | 'notebook') { +export function getChannelKindFromType( + type: Omit +) { if (type === 'chat') { return 'chat'; } else if (type === 'gallery') { diff --git a/packages/ui/package.json b/packages/ui/package.json index 4c2a20a5d1..a2c5448d85 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -45,9 +45,10 @@ "moti": "^0.28.1", "react-hook-form": "^7.52.0", "react-native-context-menu-view": "^1.15.0", - "react-native-safe-area-context": "^4.9.0", "react-native-gesture-handler": "~2.16.2", + "react-native-safe-area-context": "^4.9.0", "react-native-svg": "^15.0.0", + "react-qr-code": "^2.0.12", "react-tweet": "^3.0.4", "tamagui": "~1.112.12", "urbit-ob": "^5.0.1" diff --git a/packages/ui/src/components/ActionSheet.tsx b/packages/ui/src/components/ActionSheet.tsx index fc3a513b19..78d3a93669 100644 --- a/packages/ui/src/components/ActionSheet.tsx +++ b/packages/ui/src/components/ActionSheet.tsx @@ -28,7 +28,7 @@ import { Icon, IconType } from './Icon'; import { ListItem } from './ListItem'; import { Sheet } from './Sheet'; -export type Accent = 'positive' | 'negative' | 'neutral' | 'disabled'; +type Accent = 'positive' | 'negative' | 'neutral' | 'disabled'; export type Action = { title: string; @@ -117,7 +117,9 @@ const ActionSheetComponent = ({ borderWidth={1} borderColor="$border" padding={0} - minWidth={300} + width="50%" + maxWidth={800} + minWidth={400} key="content" > {children} @@ -487,7 +489,7 @@ export const SimpleActionSheetHeader = ({ subtitle, icon, }: { - title?: string; + title?: string | null; subtitle?: string; icon?: ReactElement; }) => { diff --git a/packages/ui/src/components/Activity/ActivityHeader.tsx b/packages/ui/src/components/Activity/ActivityHeader.tsx index 380c1779cc..3bd4e64466 100644 --- a/packages/ui/src/components/Activity/ActivityHeader.tsx +++ b/packages/ui/src/components/Activity/ActivityHeader.tsx @@ -27,21 +27,27 @@ function ActivityHeaderRaw({ onTabPress={() => onTabPress('all')} name="all" > - All + + All + onTabPress('mentions')} name="mentions" > - Mentions + + Mentions + onTabPress('replies')} name="replies" > - Replies + + Replies + diff --git a/packages/ui/src/components/Activity/ActivityListItem.tsx b/packages/ui/src/components/Activity/ActivityListItem.tsx index 9f54879626..96760b4a17 100644 --- a/packages/ui/src/components/Activity/ActivityListItem.tsx +++ b/packages/ui/src/components/Activity/ActivityListItem.tsx @@ -5,7 +5,7 @@ import React, { PropsWithChildren, useCallback, useMemo } from 'react'; import { XStack, YStack, styled } from 'tamagui'; import { useCalm } from '../../contexts'; -import { useChannelTitle } from '../../utils'; +import { getGroupTitle, useChannelTitle, useGroupTitle } from '../../utils'; import { ChannelAvatar, ContactAvatar, GroupAvatar } from '../Avatar'; import { Icon } from '../Icon'; import Pressable from '../Pressable'; @@ -72,10 +72,11 @@ export function ActivityListItemContent({ return (isGroupUnread(unread) ? unread.notifyCount : unread?.count) ?? 0; }, [unread]); + const groupTitle = useGroupTitle(group ?? null); const channelTitle = useChannelTitle(channel ?? null); const title = useMemo(() => { if (channel == null || channelTitle == null) { - return group?.title ?? ''; + return groupTitle ?? ''; } if (channel.type === 'dm') { return 'Direct message'; @@ -83,11 +84,8 @@ export function ActivityListItemContent({ if (channel.type === 'groupDm') { return 'Group chat'; } - if (group?.title) { - return `${group.title}: ${channelTitle}`; - } - return channelTitle; - }, [channelTitle, channel, group]); + return `${groupTitle}: ${channelTitle}`; + }, [channel, channelTitle, groupTitle]); return ( void; -}) { - const [loading, setLoading] = useState(false); - const [groupName, setGroupName] = useState(''); - - const onCreateGroup = useCallback(async () => { - const shortCode = createShortCodeFromTitle(groupName); - if (groupName.length < 3 || shortCode.length < 3) { - return; - } - - setLoading(true); - - try { - const { group, channel } = await store.createGroup({ - title: groupName, - shortCode, - }); - - if (props.invitees.length > 0) { - await store.inviteGroupMembers({ - groupId: group.id, - contactIds: props.invitees, - }); - } - - props.onCreatedGroup({ group, channel }); - triggerHaptic('success'); - } catch (e) { - console.error(e); - } finally { - setLoading(false); - } - }, [groupName, props]); - - return ( - - - - - - Create Group - - - ); -} diff --git a/packages/ui/src/components/AddChats/ViewUserGroupsWidget.tsx b/packages/ui/src/components/AddChats/ViewUserGroupsWidget.tsx deleted file mode 100644 index ead6c655dd..0000000000 --- a/packages/ui/src/components/AddChats/ViewUserGroupsWidget.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import * as db from '@tloncorp/shared/db'; -import * as store from '@tloncorp/shared/store'; -import { useCallback, useMemo, useRef } from 'react'; -import { NativeScrollEvent, NativeSyntheticEvent } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { ScrollView, View, YStack } from 'tamagui'; - -import { Badge } from '../Badge'; -import { GroupListItem, ListItem, ListItemProps } from '../ListItem'; -import { Text } from '../TextV2'; - -export function ViewUserGroupsWidget({ - userId, - onSelectGroup, - onScrollChange, -}: { - userId: string; - onSelectGroup: (group: db.Group) => void; - onScrollChange?: (scrolling: boolean) => void; -}) { - const { data, isError, isLoading } = store.useGroupsHostedBy(userId); - - const scrollPosition = useRef(0); - const handleScroll = useCallback( - (event: NativeSyntheticEvent) => { - scrollPosition.current = event.nativeEvent.contentOffset.y; - }, - [] - ); - const onTouchStart = useCallback(() => { - if (scrollPosition.current > 0) { - onScrollChange?.(true); - } - }, [onScrollChange]); - - const onTouchEnd = useCallback( - () => onScrollChange?.(false), - [onScrollChange] - ); - - const insets = useSafeAreaInsets(); - - return ( - - { - <> - - {isLoading ? ( - - ) : isError ? ( - - ) : data && data.length > 0 ? ( - data?.map((group) => ( - - )) - ) : ( - - )} - - - } - - ); -} - -function PlaceholderMessage({ text }: { text: string }) { - return ( - - - {text} - - - ); -} - -function GroupPreviewListItem({ model, onPress }: ListItemProps) { - const badgeText = useMemo(() => { - if (model.currentUserIsMember) { - return 'Joined'; - } - return model.privacy === 'private' ? 'Private' : ''; - }, [model.currentUserIsMember, model.privacy]); - - return ( - - - - ) : undefined - } - /> - ); -} diff --git a/packages/ui/src/components/AddChats/index.tsx b/packages/ui/src/components/AddChats/index.tsx deleted file mode 100644 index 83ebc5aa64..0000000000 --- a/packages/ui/src/components/AddChats/index.tsx +++ /dev/null @@ -1,2 +0,0 @@ -export * from './CreateGroupWidget'; -export * from './ViewUserGroupsWidget'; diff --git a/packages/ui/src/components/AddGroupSheet.tsx b/packages/ui/src/components/AddGroupSheet.tsx deleted file mode 100644 index 0a5398d3d5..0000000000 --- a/packages/ui/src/components/AddGroupSheet.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { useCallback, useEffect, useState } from 'react'; -import { View, YStack } from 'tamagui'; - -import { triggerHaptic } from '../utils'; -import { ActionSheet } from './ActionSheet'; -import { ContactBook } from './ContactBook'; -import { IconType } from './Icon'; -import { ListItem } from './ListItem'; -import Pressable from './Pressable'; - -export function AddGroupSheet({ - open, - onOpenChange, - onGoToDm, - navigateToFindGroups, - navigateToCreateGroup, -}: { - open: boolean; - onOpenChange: (open: boolean) => void; - onGoToDm: (userId: string) => void; - navigateToFindGroups: () => void; - navigateToCreateGroup: () => void; -}) { - const [screenScrolling, setScreenScrolling] = useState(false); - const [screenKey, setScreenKey] = useState(0); - - const dismiss = useCallback(() => { - onOpenChange(false); - // used for resetting components nested within screens after - // reopening - setTimeout(() => { - setScreenKey((key) => key + 1); - }, 300); - }, [onOpenChange]); - - useEffect(() => { - if (open) { - triggerHaptic('sheetOpen'); - } - }, [open]); - - const onSelect = useCallback( - (contactId: string) => { - onGoToDm(contactId); - }, - [onGoToDm] - ); - - return ( - - - - - - - - } - /> - - - ); -} - -function QuickAction({ - onPress, - icon, - title, - subtitle, -}: { - onPress: () => void; - icon: IconType; - title: string; - subtitle?: string; -}) { - return ( - - - - - {title} - {subtitle} - - - - ); -} diff --git a/packages/ui/src/components/AuthorRow.tsx b/packages/ui/src/components/AuthorRow.tsx index 04944762d3..77d5e7a718 100644 --- a/packages/ui/src/components/AuthorRow.tsx +++ b/packages/ui/src/components/AuthorRow.tsx @@ -83,16 +83,17 @@ export function DetailViewAuthorRow({ const shouldTruncate = showEditedIndicator || deliveryFailed; return ( - - + + - + > + + {deliveryFailed ? ( Tap to retry @@ -133,16 +134,17 @@ export function ChatAuthorRow({ const shouldTruncate = showEditedIndicator || firstRole || deliveryFailed; return ( - - + + - + > + + {timeDisplay && ( {timeDisplay} diff --git a/packages/ui/src/components/Avatar.tsx b/packages/ui/src/components/Avatar.tsx index d437509bd4..3e65479fcb 100644 --- a/packages/ui/src/components/Avatar.tsx +++ b/packages/ui/src/components/Avatar.tsx @@ -76,21 +76,25 @@ export type AvatarProps = ComponentProps & { export const ContactAvatar = React.memo(function ContactAvatComponent({ contactId, + contactOverride, overrideUrl, innerSigilSize, ...props }: { contactId: string; + contactOverride?: db.Contact; overrideUrl?: string; innerSigilSize?: number; } & AvatarProps) { - const contact = useContact(contactId); + const dbContact = useContact(contactId); + const contact = contactOverride ?? dbContact; return ( @@ -110,9 +114,15 @@ export const GroupAvatar = React.memo(function GroupAvatarComponent({ model, ...props }: { model: db.Group | GroupImageShim } & AvatarProps) { + const { disableNicknames } = useCalm(); + const fallbackTitle = useMemo(() => { + return isGroupImageShim(model) + ? model.title + : utils.getGroupTitle(model, disableNicknames); + }, [disableNicknames, model]); const fallback = ( @@ -126,6 +136,12 @@ export const GroupAvatar = React.memo(function GroupAvatarComponent({ ); }); +function isGroupImageShim( + group: db.Group | GroupImageShim +): group is GroupImageShim { + return !('description' in group); +} + export const ChannelAvatar = React.memo(function ChannelAvatarComponent({ model, useTypeIcon, @@ -229,6 +245,7 @@ export const ImageAvatar = function ImageAvatarComponent({ const isSVG = imageUrl?.endsWith('.svg'); return imageUrl && + imageUrl !== '' && !isSVG && (props.ignoreCalm || !calmSettings.disableAvatars) && !loadFailed ? ( @@ -293,11 +310,17 @@ export const TextAvatar = React.memo(function TextAvatarComponent({ export const SigilAvatar = React.memo(function SigilAvatarComponent({ contactId, + contactOverride, innerSigilSize, size = '$4xl', ...props -}: { contactId: string; innerSigilSize?: number } & AvatarProps) { - const contact = useContact(contactId); +}: { + contactId: string; + contactOverride?: db.Contact; + innerSigilSize?: number; +} & AvatarProps) { + const dbContact = useContact(contactId); + const contact = contactOverride ?? dbContact; const colors = useSigilColors(contact?.color); const styles = useStyle(props, { resolveValues: 'value' }); const sigilSize = useMemo(() => { diff --git a/packages/ui/src/components/BareChatInput/index.tsx b/packages/ui/src/components/BareChatInput/index.tsx index 3bb94f943b..c2c3f0e36f 100644 --- a/packages/ui/src/components/BareChatInput/index.tsx +++ b/packages/ui/src/components/BareChatInput/index.tsx @@ -36,6 +36,7 @@ import { UploadedImageAttachment, useAttachmentContext, } from '../../contexts'; +import { useGlobalSearch } from '../../contexts/globalSearch'; import { DEFAULT_MESSAGE_INPUT_HEIGHT } from '../MessageInput'; import { AttachmentPreviewList } from '../MessageInput/AttachmentPreviewList'; import { @@ -353,25 +354,39 @@ export default function BareChatInput({ metadata['image'] = attachment.uploadState.remoteUri; } - if (isEdit && editingPost) { - if (editingPost.parentId) { - await editPost?.(editingPost, story, editingPost.parentId, metadata); + try { + setControlledText(''); + bareChatInputLogger.log('clearing attachments'); + clearAttachments(); + bareChatInputLogger.log('resetting input height'); + setInputHeight(initialHeight); + + if (isEdit && editingPost) { + if (editingPost.parentId) { + await editPost?.( + editingPost, + story, + editingPost.parentId, + metadata + ); + } + await editPost?.(editingPost, story, undefined, metadata); + setEditingPost?.(undefined); + } else { + await send(story, channelId, metadata); } - await editPost?.(editingPost, story, undefined, metadata); - setEditingPost?.(undefined); - } else { - // not awaiting since we don't want to wait for the send to complete - // before clearing the draft and the editor content - send(story, channelId, metadata); + } catch (e) { + bareChatInputLogger.error('Error sending message', e); + setSendError(true); + } finally { + onSend?.(); + bareChatInputLogger.log('sent message', story); + setMentions([]); + bareChatInputLogger.log('clearing draft'); + clearDraft(); + bareChatInputLogger.log('setting initial content'); + setHasSetInitialContent(false); } - - onSend?.(); - setControlledText(''); - setMentions([]); - clearAttachments(); - clearDraft(); - setHasSetInitialContent(false); - setInputHeight(initialHeight); }, [ onSend, @@ -628,10 +643,35 @@ export default function BareChatInput({ } }; + const { setIsOpen } = useGlobalSearch(); + const handleBlur = useCallback(() => { setShouldBlur(true); }, [setShouldBlur]); + const handleKeyPress = useCallback( + (e: any) => { + const keyEvent = e.nativeEvent as unknown as KeyboardEvent; + if (!isWeb) return; + + if ( + (keyEvent.metaKey || keyEvent.ctrlKey) && + keyEvent.key.toLowerCase() === 'k' + ) { + e.preventDefault(); + inputRef.current?.blur(); + setIsOpen(true); + return; + } + + if (keyEvent.key === 'Enter' && !keyEvent.shiftKey) { + e.preventDefault(); + handleSend(); + } + }, + [setIsOpen, handleSend] + ); + return ( { - if (isWeb && e.nativeEvent.key === 'Enter') { - const keyEvent = e.nativeEvent as unknown as KeyboardEvent; - if (!keyEvent.shiftKey) { - e.preventDefault(); - handleSend(); - } - } - }} + onKeyPress={handleKeyPress} multiline placeholder={placeholder} {...(!isWeb ? placeholderTextColor : {})} diff --git a/packages/ui/src/components/Button.tsx b/packages/ui/src/components/Button.tsx index 76a3b42363..3633833b2d 100644 --- a/packages/ui/src/components/Button.tsx +++ b/packages/ui/src/components/Button.tsx @@ -34,6 +34,7 @@ export const ButtonContext = createStyledContext<{ export const ButtonFrame = styled(Stack, { name: 'Button', + cursor: 'pointer', context: ButtonContext, backgroundColor: '$background', alignItems: 'center', @@ -47,6 +48,7 @@ export const ButtonFrame = styled(Stack, { borderRadius: '$l', paddingVertical: '$s', paddingHorizontal: '$l', + gap: '$s', variants: { size: { '...size': (name, { tokens }) => { @@ -110,9 +112,8 @@ export const ButtonFrame = styled(Stack, { }, secondary: { true: { - backgroundColor: '$border', - padding: '$xl', - borderWidth: 0, + height: 56, + borderColor: '$shadow', pressStyle: { backgroundColor: '$secondaryBackground', }, @@ -169,9 +170,7 @@ export const ButtonText = styled(Text, { }, secondary: { true: { - width: '100%', - textAlign: 'center', - fontWeight: '500', + color: '$secondaryText', }, }, disabled: {} as Record<'true' | 'false', ViewStyle>, @@ -179,18 +178,16 @@ export const ButtonText = styled(Text, { }); const ButtonIcon = (props: { color?: ColorTokens; children: any }) => { - const { size, color, hero, heroDestructive } = useContext( - ButtonContext.context - ); - const smaller = getSize(size, { - shift: -1, - }); + const context = useContext(ButtonContext.context); + + const iconColor = + props.color ?? + (context.hero || context.heroDestructive + ? '$background' + : context.color ?? '$primaryText'); + return cloneElement(props.children, { - size: smaller.val, - color: - props.color ?? - color ?? - (hero || heroDestructive ? '$white' : '$primaryText'), + color: iconColor, }); }; diff --git a/packages/ui/src/components/Channel/BaubleHeader.tsx b/packages/ui/src/components/Channel/BaubleHeader.tsx index bfd260208e..b732f7b157 100644 --- a/packages/ui/src/components/Channel/BaubleHeader.tsx +++ b/packages/ui/src/components/Channel/BaubleHeader.tsx @@ -20,6 +20,7 @@ import { Spinner, Text, View } from 'tamagui'; import { useChatOptions } from '../../contexts/chatOptions'; import { useScrollContext } from '../../contexts/scroll'; +import { useGroupTitle } from '../../utils'; import { ContactAvatar } from '../Avatar'; import { Icon } from '../Icon'; import { Image } from '../Image'; @@ -40,6 +41,7 @@ export function BaubleHeader({ const [scrollValue] = useScrollContext(); const insets = useSafeAreaInsets(); const frame = useSafeAreaFrame(); + const groupTitle = useGroupTitle(group); const easedValue = useDerivedValue( () => Easing.ease(scrollValue.value), @@ -168,7 +170,7 @@ export function BaubleHeader({ exiting={FadeOut.duration(128)} > {!hideTime ? `${time}` : null} {!hideTime && unreadCount ? ' • ' : null} diff --git a/packages/ui/src/components/Channel/EmptyChannelNotice.tsx b/packages/ui/src/components/Channel/EmptyChannelNotice.tsx index 5cf4a05368..a0e869f499 100644 --- a/packages/ui/src/components/Channel/EmptyChannelNotice.tsx +++ b/packages/ui/src/components/Channel/EmptyChannelNotice.tsx @@ -1,10 +1,13 @@ import * as db from '@tloncorp/shared/db'; -import { useMemo, useState } from 'react'; -import { SizableText, YStack } from 'tamagui'; +import { YStack, styled } from 'tamagui'; -import { useGroup } from '../../contexts'; +import { useChatOptions, useGroup } from '../../contexts'; import { useIsAdmin } from '../../utils'; +import { ArvosDiscussing } from '../ArvosDiscussing'; +import { Button } from '../Button'; +import { Icon } from '../Icon'; import { InviteFriendsToTlonButton } from '../InviteFriendsToTlonButton'; +import { Text } from '../TextV2'; export function EmptyChannelNotice({ channel, @@ -13,33 +16,42 @@ export function EmptyChannelNotice({ channel: db.Channel; userId: string; }) { - const isGroupAdmin = useIsAdmin(channel.groupId ?? '', userId); + const { onPressGroupMeta } = useChatOptions(); const group = useGroup(channel.groupId ?? ''); - const [isFirstVisit] = useState(() => channel.lastViewedAt == null); - const isWelcomeChannel = !!channel.isDefaultWelcomeChannel; - const noticeText = useMemo(() => { - if (isGroupAdmin && isFirstVisit && isWelcomeChannel) { - return 'This is your group’s default welcome channel. Feel free to rename it or create additional channels.'; - } - - return 'There are no messages... yet.'; - }, [isGroupAdmin, isFirstVisit, isWelcomeChannel]); + const isGroupAdmin = useIsAdmin(channel.groupId ?? '', userId); + const isWelcomeNotice = isGroupAdmin && group?.channels?.length === 1; - return ( + return isWelcomeNotice ? ( - - - {noticeText} - - - {isGroupAdmin && isWelcomeChannel && ( + Welcome to your group! + + + - )} + + + ) : ( + + There are no messages... yet. ); } + +const TitleText = styled(Text, { + color: '$tertiaryText', + size: '$label/l', + textAlign: 'center', +}); diff --git a/packages/ui/src/components/Channel/Scroller.tsx b/packages/ui/src/components/Channel/Scroller.tsx index 489b71a071..57b125afc3 100644 --- a/packages/ui/src/components/Channel/Scroller.tsx +++ b/packages/ui/src/components/Channel/Scroller.tsx @@ -1,11 +1,10 @@ import { PostCollectionLayoutType, configurationFromChannel, + createDevLogger, layoutForType, - layoutTypeFromChannel, useMutableCallback, } from '@tloncorp/shared'; -import { createDevLogger } from '@tloncorp/shared'; import * as db from '@tloncorp/shared/db'; import { isSameDay } from '@tloncorp/shared/logic'; import { isEqual } from 'lodash'; @@ -32,7 +31,7 @@ import { } from 'react-native'; import Animated from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { View, getTokenValue, styled, useStyle, useTheme } from 'tamagui'; +import { View, styled, useStyle, useTheme } from 'tamagui'; import { RenderItemType } from '../../contexts/componentsKits'; import { useLivePost } from '../../contexts/requests'; @@ -77,7 +76,7 @@ const Scroller = forwardRef( showDividers = true, inverted, renderItem, - renderEmptyComponent: renderEmptyComponentFn, + renderEmptyComponent, posts, channel, collectionLayoutType, @@ -118,7 +117,7 @@ const Scroller = forwardRef( showReplies?: boolean; editingPost?: db.Post; setEditingPost?: (post: db.Post | undefined) => void; - onPressRetry: (post: db.Post) => void; + onPressRetry?: (post: db.Post) => Promise; onPressDelete: (post: db.Post) => void; hasNewerPosts?: boolean; activeMessage: db.Post | null; @@ -385,20 +384,6 @@ const Scroller = forwardRef( onStartReached?.(); }, [onStartReached, readyToDisplayPosts]); - const renderEmptyComponent = useCallback(() => { - return ( - - {renderEmptyComponentFn?.()} - - ); - }, [renderEmptyComponentFn]); - const [isAtBottom, setIsAtBottom] = useState(true); const handleScroll = useScrollDirectionTracker({ setIsAtBottom }); @@ -601,7 +586,7 @@ const BaseScrollerItem = ({ setViewReactionsPost?: (post: db.Post) => void; onPressPost?: (post: db.Post) => void; onLongPressPost: (post: db.Post) => void; - onPressRetry: (post: db.Post) => void; + onPressRetry?: (post: db.Post) => Promise; onPressDelete: (post: db.Post) => void; activeMessage?: db.Post | null; messageRef: RefObject; @@ -770,7 +755,7 @@ function useAnchorScrollLock({ return; } if (userHasScrolled) { - logger.log('bail: !userHasScrolled'); + logger.log('bail: userHasScrolled'); return; } if (anchorIndex === -1) { diff --git a/packages/ui/src/components/Channel/index.tsx b/packages/ui/src/components/Channel/index.tsx index aa4b3bf12c..e84cc81df6 100644 --- a/packages/ui/src/components/Channel/index.tsx +++ b/packages/ui/src/components/Channel/index.tsx @@ -1,10 +1,8 @@ import { DraftInputId, + isChatChannel as getIsChatChannel, layoutForType, layoutTypeFromChannel, -} from '@tloncorp/shared'; -import { - isChatChannel as getIsChatChannel, useChannelPreview, useGroupPreview, usePostReference as usePostReferenceHook, @@ -12,7 +10,6 @@ import { } from '@tloncorp/shared'; import * as db from '@tloncorp/shared/db'; import { JSONContent, Story } from '@tloncorp/shared/urbit'; -import { ImagePickerAsset } from 'expo-image-picker'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { FlatList } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; @@ -31,12 +28,13 @@ import { NavigationProvider, useCurrentUserId, } from '../../contexts'; -import { Attachment, AttachmentProvider } from '../../contexts/attachment'; +import { useAttachmentContext } from '../../contexts/attachment'; import { ComponentsKitContextProvider } from '../../contexts/componentsKits'; import { RequestsProvider } from '../../contexts/requests'; import { ScrollContextProvider } from '../../contexts/scroll'; import useIsWindowNarrow from '../../hooks/useIsWindowNarrow'; import * as utils from '../../utils'; +import { FileDrop } from '../FileDrop'; import { GroupPreviewAction, GroupPreviewSheet } from '../GroupPreviewSheet'; import { DraftInputContext } from '../draftInputs'; import { DraftInputHandle, GalleryDraftType } from '../draftInputs/shared'; @@ -86,12 +84,9 @@ export function Channel({ editPost, onPressRetry, onPressDelete, - canUpload, - uploadAsset, negotiationMatch, hasNewerPosts, hasOlderPosts, - initialAttachments, startDraft, onPressScrollToBottom, }: { @@ -109,7 +104,6 @@ export function Channel({ goToSearch: () => void; goToUserProfile: (userId: string) => void; messageSender: (content: Story, channelId: string) => Promise; - uploadAsset: (asset: ImagePickerAsset, isWeb?: boolean) => Promise; onScrollEndReached?: () => void; onScrollStartReached?: () => void; isLoadingPosts?: boolean; @@ -126,20 +120,18 @@ export function Channel({ editingPost?: db.Post; setEditingPost?: (post: db.Post | undefined) => void; editPost: (post: db.Post, content: Story) => Promise; - onPressRetry: (post: db.Post) => void; + onPressRetry: (post: db.Post) => Promise; onPressDelete: (post: db.Post) => void; - initialAttachments?: Attachment[]; negotiationMatch: boolean; hasNewerPosts?: boolean; hasOlderPosts?: boolean; - canUpload: boolean; startDraft?: boolean; onPressScrollToBottom?: () => void; }) { const [activeMessage, setActiveMessage] = useState(null); const [inputShouldBlur, setInputShouldBlur] = useState(false); const [groupPreview, setGroupPreview] = useState(null); - const title = utils.useChannelTitle(channel); + const title = utils.useChatTitle(channel, group); const groups = useMemo(() => (group ? [group] : null), [group]); const currentUserId = useCurrentUserId(); const canWrite = utils.useCanWrite(channel, currentUserId); @@ -282,6 +274,8 @@ export function Channel({ [channel] ); + const { attachAssets } = useAttachmentContext(); + useEffect(() => { if (startDraft) { draftInputRef.current?.startDraft?.(); @@ -311,160 +305,153 @@ export function Channel({ onPressGoToDm={goToDm} onGoToUserProfile={goToUserProfile} > - - - - - <> - - - - {draftInputPresentationMode !== 'fullscreen' && ( - - {channel && posts && ( - 0 - ? initialChannelUnread?.firstUnreadPostId - : null - } - unreadCount={ - initialChannelUnread?.countWithoutThreads ?? - 0 - } - onPressPost={ - isChatChannel ? undefined : goToPost - } - onPressReplies={goToPost} - onPressImage={goToImageViewer} - onEndReached={onScrollEndReached} - onStartReached={onScrollStartReached} - onPressRetry={onPressRetry} - onPressDelete={onPressDelete} - activeMessage={activeMessage} - setActiveMessage={setActiveMessage} - ref={flatListRef} - headerMode={headerMode} - isLoading={isLoadingPosts} - onPressScrollToBottom={ - onPressScrollToBottom - } - /> - )} - - )} - - - {canWrite && - (channel.contentConfiguration == null ? ( - <> - {isChatChannel && - !channel.isDmInvite && - (negotiationMatch ? ( - - ) : ( - - - - ))} - - {channel.type === 'gallery' && ( - - )} + + + + <> + + + + + {draftInputPresentationMode !== 'fullscreen' && ( + + {channel && posts && ( + 0 + ? initialChannelUnread?.firstUnreadPostId + : null + } + unreadCount={ + initialChannelUnread?.countWithoutThreads ?? + 0 + } + onPressPost={ + isChatChannel ? undefined : goToPost + } + onPressReplies={goToPost} + onPressImage={goToImageViewer} + onEndReached={onScrollEndReached} + onStartReached={onScrollStartReached} + onPressRetry={onPressRetry} + onPressDelete={onPressDelete} + activeMessage={activeMessage} + setActiveMessage={setActiveMessage} + ref={flatListRef} + headerMode={headerMode} + isLoading={isLoadingPosts} + onPressScrollToBottom={ + onPressScrollToBottom + } + /> + )} + + )} + - {channel.type === 'notebook' && ( + {canWrite && + (channel.contentConfiguration == null ? ( + <> + {isChatChannel && + !channel.isDmInvite && + (negotiationMatch ? ( - )} - - ) : ( - - ))} - - {channel.isDmInvite && ( - + + + ))} + + {channel.type === 'gallery' && ( + + )} + + {channel.type === 'notebook' && ( + + )} + + ) : ( + - )} - - {headerMode === 'next' ? ( - - ) : null} - setGroupPreview(null)} - onActionComplete={handleGroupAction} + )} + + {headerMode === 'next' ? ( + - - - - - + ) : null} + setGroupPreview(null)} + onActionComplete={handleGroupAction} + /> + + + + diff --git a/packages/ui/src/components/ChannelFromTemplateView.tsx b/packages/ui/src/components/ChannelFromTemplateView.tsx index c5bbbaff3e..a64abf339d 100644 --- a/packages/ui/src/components/ChannelFromTemplateView.tsx +++ b/packages/ui/src/components/ChannelFromTemplateView.tsx @@ -1,9 +1,9 @@ import { AnalyticsEvent, + createChannel, createDevLogger, deleteChannel, useChannelHooksPreview, - useCreateChannel, } from '@tloncorp/shared'; import * as api from '@tloncorp/shared/api'; import * as db from '@tloncorp/shared/db'; @@ -58,11 +58,6 @@ export function ChannelFromTemplateView({ }, }); - const createChannel = useCreateChannel({ - group: selectedGroup, - currentUserId, - }); - const onConfirm = useCallback( async (data: { title: string }) => { logger.log('onConfirm', channel, selectedGroup); @@ -73,6 +68,7 @@ export function ChannelFromTemplateView({ logger.log('creating channel', data); // create channel const newChannel = await createChannel({ + groupId: selectedGroup.id, title: data.title, channelType: channel.type, }); diff --git a/packages/ui/src/components/ChannelSwitcherSheet.tsx b/packages/ui/src/components/ChannelSwitcherSheet.tsx index f61989ed03..7bc3674a8b 100644 --- a/packages/ui/src/components/ChannelSwitcherSheet.tsx +++ b/packages/ui/src/components/ChannelSwitcherSheet.tsx @@ -5,6 +5,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { SizableText, Text, XStack } from 'tamagui'; import { useChatOptions } from '../contexts'; +import { useGroupTitle } from '../utils'; import ChannelNavSections from './ChannelNavSections'; import { Icon } from './Icon'; import Pressable from './Pressable'; @@ -44,6 +45,8 @@ export function ChannelSwitcherSheet({ chatOptions.open(group.id, 'group'); }, [chatOptions, group.id, onOpenChange]); + const title = useGroupTitle(group); + return ( - {group?.title} + {title} { - chatOptions.open(item.id, item.type); + if (!item.isPending) { + chatOptions.open(item.id, item.type); + } }, [chatOptions] ); @@ -183,15 +187,15 @@ export const ChatList = React.memo(function ChatListComponent({ ); }); -function getItemType(item: ChatListItemData) { +export function getItemType(item: ChatListItemData) { return isSectionHeader(item) ? 'sectionHeader' : item.type; } -function isSectionHeader(data: ChatListItemData): data is SectionHeaderData { +export function isSectionHeader(data: ChatListItemData): data is SectionHeaderData { return 'type' in data && data.type === 'sectionHeader'; } -function getChatKey(chatItem: ChatListItemData) { +export function getChatKey(chatItem: ChatListItemData) { if (!chatItem || typeof chatItem !== 'object') { return 'invalid-item'; } @@ -213,13 +217,19 @@ function ChatListTabs({ return ( - All + + All + - Groups + + Groups + - Messages + + Messages + ); @@ -238,6 +248,7 @@ const ChatListSearch = React.memo(function ChatListSearchComponent({ onPressClear: () => void; onPressClose: () => void; }) { + const theme = useTheme(); const [contentHeight, setContentHeight] = useState(0); const openProgress = useSharedValue(isOpen ? 1 : 0); @@ -273,7 +284,7 @@ const ChatListSearch = React.memo(function ChatListSearchComponent({ { - const nickname = member.contact - ? (member.contact as db.Contact).nickname - : null; - return nickname && !disableNicknames ? nickname : member.contactId; - }) - .join(', '); - } else { - return []; - } + return getChannelTitle({ + disableNicknames, + channelTitle: chat.channel.title, + members: chat.channel.members, + usesMemberListAsFallbackTitle: configurationFromChannel(chat.channel) + .usesMemberListAsFallbackTitle, + }); } else { - if (chat.group.title) { - return chat.group.title; - } else { - return []; - } + return getGroupTitle(chat.group, disableNicknames); } } diff --git a/packages/ui/src/components/ChatMessage/ChatMessage.tsx b/packages/ui/src/components/ChatMessage/ChatMessage.tsx index 4b9133b167..d564612d5a 100644 --- a/packages/ui/src/components/ChatMessage/ChatMessage.tsx +++ b/packages/ui/src/components/ChatMessage/ChatMessage.tsx @@ -14,6 +14,7 @@ import { import Pressable from '../Pressable'; import { SendPostRetrySheet } from '../SendPostRetrySheet'; import { Text } from '../TextV2'; +import { ChatMessageDeliveryStatus } from './ChatMessageDeliveryStatus'; import { ChatMessageReplySummary } from './ChatMessageReplySummary'; import { ReactionsDisplay } from './ReactionsDisplay'; @@ -41,7 +42,7 @@ const ChatMessage = ({ onPressImage?: (post: db.Post, imageUri?: string) => void; onPress?: (post: db.Post) => void; onLongPress?: (post: db.Post) => void; - onPressRetry?: (post: db.Post) => void; + onPressRetry?: (post: db.Post) => Promise; onPressDelete?: (post: db.Post) => void; setViewReactionsPost?: (post: db.Post) => void; isHighlighted?: boolean; @@ -87,8 +88,12 @@ const ChatMessage = ({ [onPressImage, post] ); - const handleRetryPressed = useCallback(() => { - onPressRetry?.(post); + const handleRetryPressed = useCallback(async () => { + try { + await onPressRetry?.(post); + } catch (e) { + console.error('Failed to retry post', e); + } setShowRetrySheet(false); }, [onPressRetry, post]); @@ -173,6 +178,26 @@ const ChatMessage = ({ /> + {/** we need to show delivery status even if showAuthor is false + previously we were only showing delivery status if showAuthor was true + (i.e., on the first of a series of messages) + */} + {!showAuthor && + !!post.deliveryStatus && + post.deliveryStatus !== 'failed' ? ( + + + + ) : null} + + {!showAuthor && deliveryFailed ? ( + + + Tap to retry + + + ) : null} + handleAction({ id: actionId, @@ -151,7 +152,7 @@ function CopyJsonAction({ post }: { post: db.Post }) { }, [post.content]); const { doCopy, didCopy } = useCopy(jsonString); return ( - + {!didCopy ? 'Copy post JSON' : 'Copied'} ); diff --git a/packages/ui/src/components/ChatOptionsSheet.tsx b/packages/ui/src/components/ChatOptionsSheet.tsx index d870cc40a0..ab32ec9f76 100644 --- a/packages/ui/src/components/ChatOptionsSheet.tsx +++ b/packages/ui/src/components/ChatOptionsSheet.tsx @@ -1,5 +1,3 @@ -import { useQuery } from '@tanstack/react-query'; -import { sync } from '@tloncorp/shared'; import * as db from '@tloncorp/shared/db'; import * as logic from '@tloncorp/shared/logic'; import * as store from '@tloncorp/shared/store'; @@ -11,14 +9,18 @@ import React, { useMemo, useState, } from 'react'; -import { Alert } from 'react-native'; -import { isWeb } from 'tamagui'; import { ChevronLeft } from '../assets/icons'; -import { useChatOptions, useCurrentUserId } from '../contexts'; +import { useCurrentUserId } from '../contexts/appDataContext'; +import { useChatOptions } from '../contexts/chatOptions'; import * as utils from '../utils'; import { useIsAdmin } from '../utils'; -import { Action, ActionGroup, ActionSheet } from './ActionSheet'; +import { + Action, + ActionGroup, + ActionSheet, + createActionGroups, +} from './ActionSheet'; import { IconButton } from './IconButton'; import { ListItem } from './ListItem'; @@ -32,31 +34,22 @@ type ChatOptionsSheetProps = { }; export const ChatOptionsSheet = React.memo(function ChatOptionsSheet({ - open, - onOpenChange, chat, + ...props }: ChatOptionsSheetProps) { - if (!chat || !open) { + const { group } = useChatOptions(); + + if (!chat || !props.open) { return null; } if (chat.type === 'group') { - return ( - - ); + return ; + } else if (group?.id && group?.channels?.length === 1) { + return ; } - return ( - - ); + return ; }); export function GroupOptionsSheetLoader({ @@ -68,207 +61,104 @@ export function GroupOptionsSheetLoader({ open: boolean; onOpenChange: (open: boolean) => void; }) { - const groupQuery = store.useGroup({ id: groupId }); const [pane, setPane] = useState< - 'initial' | 'edit' | 'notifications' | 'sort' + 'initial' | 'notifications' | 'sort' | 'edit' >('initial'); - const openChangeHandler = useCallback( - (open: boolean) => { - if (!open) { - setPane('initial'); - } - onOpenChange(open); - }, - [onOpenChange] - ); - - return groupQuery.data ? ( - - + const { group } = useChatOptions(); + + const handlePressNotifications = useCallback(() => { + setPane('notifications'); + }, [setPane]); + + const handlePressSort = useCallback(() => { + setPane('sort'); + }, [setPane]); + + const handlePressEdit = useCallback(() => { + setPane('edit'); + }, [setPane]); + + const resetPane = useCallback(() => { + setPane('initial'); + }, [setPane]); + + const title = utils.useGroupTitle(group) ?? 'Loading...'; + const currentUserId = useCurrentUserId(); + const currentUserIsAdmin = useIsAdmin(groupId, currentUserId); + const { data: groupUnread, isFetched: groupUnreadIsFetched } = + store.useGroupUnread({ groupId }); + return group && groupUnreadIsFetched ? ( + + {pane === 'notifications' ? ( + + ) : pane === 'edit' ? ( + + ) : pane === 'sort' ? ( + + ) : ( + + )} ) : null; } -export function GroupOptions({ +function GroupOptionsSheetContent({ + chatTitle, group, - pane, - setPane, - onOpenChange, - unreadCount, + groupUnread, + currentUserIsAdmin, + onPressNotifications, + onPressSort, + onPressEditGroup, }: { group: db.Group; - pane: 'initial' | 'edit' | 'notifications' | 'sort'; - setPane: (pane: 'initial' | 'edit' | 'notifications' | 'sort') => void; - onOpenChange: (open: boolean) => void; - unreadCount?: number | null; + groupUnread: db.GroupUnread | null; + currentUserIsAdmin: boolean; + chatTitle: string; + onPressNotifications: () => void; + onPressSort: () => void; + onPressEditGroup: () => void; }) { - const currentUser = useCurrentUserId(); - const { data: currentVolumeLevel } = store.useGroupVolumeLevel(group.id); - const { + markGroupRead, onPressGroupMembers, - onPressGroupMeta, - onPressManageChannels, onPressInvite, - onPressGroupPrivacy, - onPressLeave, - onTogglePinned, - onSelectSort, - } = useChatOptions() ?? {}; - - useEffect(() => { - sync.syncGroup(group.id, { priority: store.SyncPriority.High }); - }, [group]); - + togglePinned, + leaveGroup, + } = useChatOptions(); + const groupRef = logic.getGroupReferencePath(group.id); + const canMarkRead = !(group.unread?.count === 0 || groupUnread?.count === 0); + const canSortChannels = (group.channels?.length ?? 0) > 1; + const canInvite = currentUserIsAdmin || group.privacy === 'public'; + const canLeave = !group.currentUserIsHost; const isPinned = group?.pin; - const currentUserIsAdmin = useIsAdmin(group.id, currentUser); - - const handleVolumeUpdate = useCallback( - (newLevel: string) => { - if (group) { - store.setGroupVolumeLevel({ - group: group, - level: newLevel as ub.NotificationLevel, - }); - } - }, - [group] - ); - - const actionNotifications: ActionGroup[] = useMemo( - () => [ - { - accent: 'neutral', - actions: [ - { - title: 'All activity', - accent: currentVolumeLevel === 'loud' ? 'positive' : 'neutral', - action: () => { - handleVolumeUpdate('loud'); - }, - endIcon: currentVolumeLevel === 'loud' ? 'Checkmark' : undefined, - }, - { - title: 'Posts, mentions, and replies', - accent: currentVolumeLevel === 'medium' ? 'positive' : 'neutral', - action: () => { - handleVolumeUpdate('medium'); - }, - endIcon: currentVolumeLevel === 'medium' ? 'Checkmark' : undefined, - }, - { - title: 'Only mentions and replies', - accent: currentVolumeLevel === 'soft' ? 'positive' : 'neutral', - action: () => { - handleVolumeUpdate('soft'); - }, - endIcon: currentVolumeLevel === 'soft' ? 'Checkmark' : undefined, - }, - { - title: 'Nothing', - accent: currentVolumeLevel === 'hush' ? 'positive' : 'neutral', - action: () => { - handleVolumeUpdate('hush'); - }, - endIcon: currentVolumeLevel === 'hush' ? 'Checkmark' : undefined, - }, - ], - }, - ], - [currentVolumeLevel, handleVolumeUpdate] - ); - - const actionEdit = useMemo(() => { - const metadataAction: Action = { - title: 'Edit group info', - description: 'Change name, description, and image', - action: () => { - onOpenChange(false); - onPressGroupMeta?.(group.id); - }, - endIcon: 'ChevronRight', - }; - - const manageChannelsAction: Action = { - title: 'Manage channels', - description: 'Add or remove channels in this group', - action: () => { - onOpenChange(false); - onPressManageChannels?.(group.id); - }, - endIcon: 'ChevronRight', - }; - - const managePrivacyAction: Action = { - title: 'Privacy', - description: 'Change who can find or join this group', - action: () => { - onOpenChange(false); - onPressGroupPrivacy?.(group.id); - }, - endIcon: 'ChevronRight', - }; - const actionEdit: ActionGroup[] = [ - { - accent: 'neutral', - actions: [metadataAction, manageChannelsAction, managePrivacyAction], - }, - ]; - return actionEdit; - }, [ - group.id, - onPressGroupMeta, - onPressGroupPrivacy, - onPressManageChannels, - onOpenChange, - ]); - - const { data: groupUnread } = useQuery({ - queryKey: ['groupUnread', group.id], - - queryFn: async () => db.getGroupUnread({ groupId: group.id }), - }); - - const handleMarkAllRead = useCallback(() => { - store.markGroupRead(group, true); - onOpenChange(false); - }, [group, onOpenChange]); - - const actionGroups = useMemo(() => { - const groupRef = logic.getGroupReferencePath(group.id); - - const actionGroups: ActionGroup[] = [ - { - accent: 'neutral', - actions: [ + const actionGroups = useMemo( + () => + createActionGroups( + [ + 'neutral', { title: 'Notifications', - action: () => { - setPane('notifications'); - }, + action: onPressNotifications, endIcon: 'ChevronRight', }, - ...(unreadCount === 0 || groupUnread?.count === 0 - ? [] - : [ - { - title: 'Mark all as read', - action: () => { - handleMarkAllRead(); - }, - }, - ]), + canMarkRead && { + title: 'Mark all as read', + action: markGroupRead, + }, { title: isPinned ? 'Unpin' : 'Pin', endIcon: 'Pin', - action: onTogglePinned, + action: togglePinned, }, { title: 'Copy group reference', @@ -277,183 +167,160 @@ export function GroupOptions({ ), }, - ], - }, - ]; - - if (group.channels && group.channels.length > 1) { - actionGroups[0].actions.push({ - title: 'Sort channels', - endIcon: 'ChevronRight', - action: () => { - setPane('sort'); - }, - }); - } - - const editAction: Action = { - title: 'Edit group', - action: () => { - setPane('edit'); - }, - endIcon: 'ChevronRight', - }; - - const goToMembersAction: Action = { - title: 'Members', - endIcon: 'ChevronRight', - action: () => { - onPressGroupMembers?.(group.id); - onOpenChange(false); - }, - }; - - const inviteAction: Action = { - title: 'Invite people', - action: () => { - onOpenChange(false); - onPressInvite?.(group); - }, - endIcon: 'ChevronRight', - }; - - const inviteNotice: Action = { - accent: 'disabled', - title: 'Invites disabled', - description: 'Only admins may invite people to this group.', - }; - - if (currentUserIsAdmin) { - actionGroups.push({ - accent: 'neutral', - actions: [editAction], - }); - } - - if (currentUserIsAdmin) { - actionGroups.push({ - accent: 'neutral', - actions: [goToMembersAction, inviteAction], - }); - } else { - actionGroups.push({ - accent: 'neutral', - actions: - group.privacy === 'public' - ? [goToMembersAction, inviteAction] - : [goToMembersAction, inviteNotice], - }); - } - - if (!group.currentUserIsHost) { - actionGroups.push({ - accent: 'negative', - actions: [ - { - title: 'Leave group', - endIcon: 'LogOut', - action: () => { - onOpenChange(false); - onPressLeave?.(); - }, + canSortChannels && { + title: 'Sort channels', + endIcon: 'ChevronRight', + action: onPressSort, }, ], - }); - } - return actionGroups; - }, [ - group, - unreadCount, - groupUnread?.count, - isPinned, - onTogglePinned, - currentUserIsAdmin, - setPane, - handleMarkAllRead, - onPressGroupMembers, - onOpenChange, - onPressInvite, - onPressLeave, - ]); - - const actionSort: ActionGroup[] = useMemo(() => { - return [ - { - accent: 'neutral', - actions: [ - { - title: 'Sort by recency', - action: () => { - onSelectSort?.('recency'); - onOpenChange(false); - }, + [ + 'neutral', + currentUserIsAdmin && { + title: 'Edit group', + action: onPressEditGroup, + endIcon: 'ChevronRight', }, { - title: 'Sort by arrangement', - action: () => { - onSelectSort?.('arranged'); - onOpenChange(false); - }, + title: 'Members', + endIcon: 'ChevronRight', + action: onPressGroupMembers, }, + canInvite + ? { + title: 'Invite people', + action: onPressInvite, + endIcon: 'ChevronRight', + } + : { + accent: 'disabled', + title: 'Invites disabled', + description: 'Only admins may invite people to this group.', + }, ], - }, - ]; - }, [onSelectSort, onOpenChange]); - - const memberCount = group?.members?.length - ? group.members.length.toLocaleString() - : 0; - const title = group?.title ?? 'Loading…'; + canLeave && [ + 'negative', + { + title: 'Leave group', + endIcon: 'LogOut', + action: leaveGroup, + }, + ] + ), + [ + canInvite, + canLeave, + canMarkRead, + canSortChannels, + currentUserIsAdmin, + groupRef, + isPinned, + leaveGroup, + markGroupRead, + onPressEditGroup, + onPressGroupMembers, + onPressInvite, + onPressNotifications, + onPressSort, + togglePinned, + ] + ); + + const memberCount = group?.members?.length ? group.members.length : 0; const privacy = group?.privacy ? group.privacy.charAt(0).toUpperCase() + group.privacy.slice(1) : ''; const subtitle = memberCount ? `${privacy} group with ${memberCount} member${group.members?.length === 1 ? '' : 's'}` : ''; + + console.log(chatTitle, subtitle, actionGroups); + + return ( + } + /> + ); +} + +function SortChannelsSheetContent({ + chatTitle, + onPressBack, +}: { + chatTitle: string; + onPressBack: () => void; +}) { + const { setChannelSortPreference } = useChatOptions(); + + const sortActions = useMemo( + () => + createActionGroups([ + 'neutral', + { + title: 'Sort by recency', + action: () => setChannelSortPreference?.('recency'), + }, + { + title: 'Sort by arrangement', + action: () => setChannelSortPreference?.('arranged'), + }, + ]), + [setChannelSortPreference] + ); + + return ( + } + /> + ); +} + +function EditGroupSheetContent({ + chatTitle, + onPressBack, +}: { + chatTitle: string; + onPressBack: () => void; +}) { + const { onPressGroupMeta, onPressManageChannels, onPressGroupPrivacy } = + useChatOptions(); + const editActions = useMemo( + () => + createActionGroups([ + 'neutral', + { + title: 'Edit group info', + description: 'Change name, description, and image', + action: onPressGroupMeta, + endIcon: 'ChevronRight', + }, + { + title: 'Manage channels', + description: 'Add or remove channels in this group', + action: onPressManageChannels, + endIcon: 'ChevronRight', + }, + { + title: 'Privacy', + description: 'Change who can find or join this group', + action: onPressGroupPrivacy, + endIcon: 'ChevronRight', + }, + ]), + [onPressGroupMeta, onPressGroupPrivacy, onPressManageChannels] + ); + return ( - ) : ( - setPane('initial')}> - - - ) - } + title={'Edit ' + chatTitle} + subtitle="Edit group details" + actionGroups={editActions} + icon={} /> ); } @@ -473,404 +340,194 @@ export function ChannelOptionsSheetLoader({ const channelQuery = store.useChannel({ id: channelId, }); + const { data: group } = store.useGroup({ + id: channelQuery.data?.groupId ?? undefined, + }); + const groupTitle = utils.useGroupTitle(group) ?? 'group'; + const channelTitle = + utils.useChannelTitle(channelQuery.data ?? null) ?? 'channel'; + const isSingleChannelGroup = group?.channels.length === 1; + const chatTitle = isSingleChannelGroup ? groupTitle : channelTitle; - const openChangeHandler = useCallback( - (open: boolean) => { - if (!open) { - setPane('initial'); - } - onOpenChange(open); - }, - [onOpenChange] - ); + const handlePressNotifications = useCallback(() => { + setPane('notifications'); + }, [setPane]); + + const resetPane = useCallback(() => { + setPane('initial'); + }, [setPane]); + + useEffect(() => { + if (!open) { + resetPane(); + } + }, [open, resetPane]); return channelQuery.data ? ( - - + + {pane === 'notifications' ? ( + + ) : ( + + )} ) : null; } -export function ChannelOptions({ +function ChannelOptionsSheetContent({ + chatTitle, channel, - pane, - setPane, - onOpenChange, + onPressNotifications, }: { + chatTitle: string; channel: db.Channel; - pane: ChannelPanes; - setPane: (pane: ChannelPanes) => void; - onOpenChange: (open: boolean) => void; + onPressNotifications: () => void; }) { - const { data: group } = store.useGroup({ - id: channel?.groupId ?? undefined, - }); - const { data: currentVolumeLevel } = store.useChannelVolumeLevel(channel.id); - const currentUser = useCurrentUserId(); const { + group, onPressChannelMembers, onPressChannelMeta, + onPressChannelTemplate, onPressManageChannels, onPressInvite, - onPressChannelTemplate, - } = useChatOptions() ?? {}; + togglePinned, + leaveChannel, + markChannelRead, + } = useChatOptions(); const { data: hooksPreview } = store.useChannelHooksPreview(channel.id); - const currentUserIsHost = useMemo( - () => group?.currentUserIsHost ?? false, - [group?.currentUserIsHost] - ); - + const currentUser = useCurrentUserId(); + const currentUserIsHost = group?.currentUserIsHost ?? false; const currentUserIsAdmin = useIsAdmin(channel.groupId ?? '', currentUser); - - const title = utils.useChannelTitle(channel); - - const subtitle = useMemo(() => { - if (!channel) { - return ''; - } - switch (channel.type) { - case 'dm': - return `Chat with ${channel.contactId}`; - case 'groupDm': - return channel.members && channel.members?.length > 2 - ? `Chat with ${channel.members[0].contactId} and ${channel.members?.length - 1} others` - : 'Group chat'; - default: - return group ? `Channel in ${group?.title}` : ''; - } - }, [channel, group]); - - const handleVolumeUpdate = useCallback( - (newLevel: string) => { - if (channel) { - store.setChannelVolumeLevel({ - channel: channel, - level: newLevel as ub.NotificationLevel, - }); - } - }, - [channel] - ); - - const actionNotifications: ActionGroup[] = useMemo( - () => [ - { - accent: 'neutral', - actions: [ + const groupTitle = utils.useGroupTitle(group) ?? 'group'; + const isSingleChannelGroup = group?.channels?.length === 1; + const invitationsEnabled = + group?.privacy === 'private' || group?.privacy === 'secret'; + const canInvite = invitationsEnabled && currentUserIsAdmin; + const canMarkRead = !(channel.unread?.count === 0); + + const actionGroups: ActionGroup[] = useMemo( + () => + createActionGroups( + [ + 'neutral', { - title: 'All activity', - accent: currentVolumeLevel === 'loud' ? 'positive' : 'neutral', - action: () => { - handleVolumeUpdate('loud'); - }, - endIcon: currentVolumeLevel === 'loud' ? 'Checkmark' : undefined, + title: 'Notifications', + endIcon: 'ChevronRight', + action: onPressNotifications, }, { - title: 'Posts, mentions, and replies', - accent: currentVolumeLevel === 'medium' ? 'positive' : 'neutral', - action: () => { - handleVolumeUpdate('medium'); - }, - endIcon: currentVolumeLevel === 'medium' ? 'Checkmark' : undefined, + title: channel?.pin ? 'Unpin' : 'Pin', + endIcon: 'Pin', + action: togglePinned, + }, + canMarkRead && { + title: 'Mark as read', + action: markChannelRead, }, + ], + channel.type === 'groupDm' && [ + 'neutral', { - title: 'Only mentions and replies', - accent: currentVolumeLevel === 'soft' ? 'positive' : 'neutral', - action: () => { - handleVolumeUpdate('soft'); - }, - endIcon: currentVolumeLevel === 'soft' ? 'Checkmark' : undefined, + title: 'Edit group info', + endIcon: 'ChevronRight', + action: onPressChannelMeta, }, { - title: 'Nothing', - accent: currentVolumeLevel === 'hush' ? 'positive' : 'neutral', - action: () => { - handleVolumeUpdate('hush'); - }, - endIcon: currentVolumeLevel === 'hush' ? 'Checkmark' : undefined, + title: 'Members', + endIcon: 'ChevronRight', + action: onPressChannelMembers, }, ], - }, - ], - [currentVolumeLevel, handleVolumeUpdate] - ); - - const handleMarkRead = useCallback(() => { - if (channel && !channel.isPendingChannel) { - store.markChannelRead(channel); - } - }, [channel]); - - const actionGroups: ActionGroup[] = useMemo(() => { - return [ - { - accent: 'neutral', - actions: [ - { - title: 'Notifications', + group && [ + 'neutral', + currentUserIsAdmin && { + title: 'Manage channels', endIcon: 'ChevronRight', - action: () => { - if (!channel) { - return; - } - setPane('notifications'); - }, - icon: 'ChevronRight', + action: onPressManageChannels, }, - { - title: channel?.pin ? 'Unpin' : 'Pin', - endIcon: 'Pin', - action: () => { - if (!channel) { - return; + canInvite + ? { + title: 'Invite people', + action: onPressInvite, + endIcon: 'ChevronRight', } - channel.pin - ? store.unpinItem(channel.pin) - : store.pinChannel(channel); - }, + : { + title: 'Invites disabled', + accent: 'disabled', + description: 'Only admins may invite people to this group.', + }, + ], + hooksPreview && [ + 'neutral', + { + title: 'Use channel as template', + description: 'Create a new channel based on this one', + endIcon: 'Copy', + action: onPressChannelTemplate, }, ], - }, - ...((channel.unread?.count ?? 0) > 0 - ? [ - { - accent: 'neutral', - actions: [ - { - title: 'Mark as read', - action: () => { - handleMarkRead(), onOpenChange(false); - }, - }, - ], - } as ActionGroup, - ] - : []), - ...(channel.type === 'groupDm' - ? [ - { - accent: 'neutral', - actions: [ - { - title: 'Edit group info', - endIcon: 'ChevronRight', - action: () => { - if (!channel) { - return; - } - onPressChannelMeta?.(channel.id); - onOpenChange(false); - }, - }, - ], - } as ActionGroup, - ] - : []), - ...(channel.type === 'groupDm' - ? [ - { - accent: 'neutral', - actions: [ - { - title: 'Members', - endIcon: 'ChevronRight', - action: () => { - if (!channel) { - return; - } - onPressChannelMembers?.(channel.id); - onOpenChange(false); - }, - }, - ], - } as ActionGroup, - ] - : []), - ...(currentUserIsAdmin - ? [ - { - accent: 'neutral', - actions: [ - { - title: 'Manage channels', - endIcon: 'ChevronRight', - action: () => { - if (!group) { - return; - } - onPressManageChannels?.(group.id); - onOpenChange(false); - }, - }, - ], - } as ActionGroup, - ] - : []), - // TODO: redefine in a more readable way. - ...(group && - !['groupDm', 'dm'].includes(channel.type) && - (group.privacy === 'public' || - (currentUserIsAdmin && - ['private', 'secret'].includes(group.privacy ?? ''))) - ? [ - { - accent: 'neutral', - actions: [ - { - title: 'Invite people', - action: () => { - onOpenChange(false); - onPressInvite?.(group); - }, - endIcon: 'ChevronRight', - }, - ], - } as ActionGroup, - ] - : []), - ...(group && - !['groupDm', 'dm'].includes(channel.type) && - !currentUserIsAdmin && - ['private', 'secret'].includes(group.privacy ?? '') - ? [ - { - accent: 'disabled', - actions: [ - { - title: 'Invites disabled', - description: 'Only admins may invite people to this group.', - }, - ], - } as ActionGroup, - ] - : []), - ...(hooksPreview - ? [ - { - accent: 'neutral', - actions: [ - { - title: 'Use channel as template', - description: 'Create a new channel based on this one', - endIcon: 'Copy', - action: () => { - onOpenChange(false); - onPressChannelTemplate(channel.id); - }, - }, - ], - } as ActionGroup, - ] - : []), - ...(!currentUserIsHost - ? [ - { - accent: 'negative', - actions: [ - { - title: `Leave`, - endIcon: 'LogOut', - action: () => { - if (!channel) { - return; - } - if (!isWeb) { - Alert.alert( - `Leave ${title}?`, - 'You will no longer receive updates from this channel.', - [ - { - text: 'Cancel', - onPress: () => console.log('Cancel Pressed'), - style: 'cancel', - }, - { - text: 'Leave', - style: 'destructive', - onPress: () => { - onOpenChange(false); - if ( - channel.type === 'dm' || - channel.type === 'groupDm' - ) { - store.respondToDMInvite({ - channel, - accept: false, - }); - } else { - store.leaveGroupChannel(channel.id); - } - }, - }, - ] - ); - return; - } - onOpenChange(false); - if (channel.type === 'dm' || channel.type === 'groupDm') { - store.respondToDMInvite({ - channel, - accept: false, - }); - } else { - store.leaveGroupChannel(channel.id); - } - }, - }, - ], - } as ActionGroup, - ] - : []), - ]; - }, [ - channel, - currentUserIsAdmin, - group, - currentUserIsHost, - hooksPreview, - setPane, - handleMarkRead, - onOpenChange, - onPressChannelMeta, - onPressChannelMembers, - onPressManageChannels, - onPressInvite, - title, - ]); + !currentUserIsHost && [ + 'negative', + { + title: `Leave`, + endIcon: 'LogOut', + action: leaveChannel, + }, + ] + ), + [ + onPressNotifications, + channel?.pin, + channel.type, + togglePinned, + canMarkRead, + markChannelRead, + onPressChannelMeta, + onPressChannelMembers, + group, + currentUserIsAdmin, + onPressManageChannels, + canInvite, + onPressInvite, + currentUserIsHost, + leaveChannel, + ] + ); - const displayTitle = useMemo((): string => { - if (pane === 'initial') { - return title ?? ''; + const subtitle = useMemo(() => { + if (!channel) { + return ''; } - if (title == null) { - return 'Notifications'; - } else { - return 'Notifications for ' + title; + switch (channel.type) { + case 'dm': + return `Chat with ${channel.contactId}`; + case 'groupDm': + return channel.members && channel.members?.length > 2 + ? `Chat with ${channel.members[0].contactId} and ${channel.members?.length - 1} others` + : 'Group chat'; + default: + return group + ? isSingleChannelGroup + ? `Group with ${group.members?.length ?? 0} members` + : `Channel in ${groupTitle}` + : ''; } - }, [title, pane]); + }, [channel, group, groupTitle, isSingleChannelGroup]); return ( - ) : ( - setPane('initial')}> - - - ) - } + title={chatTitle ?? ''} + subtitle={subtitle} + actionGroups={actionGroups} + icon={} /> ); } @@ -903,3 +560,73 @@ function ChatOptionsSheetContent({ ); } + +const notificationOptions: { title: string; value: ub.NotificationLevel }[] = [ + { + title: 'All activity', + value: 'loud', + }, + { + title: 'Posts, mentions, and replies', + value: 'medium', + }, + { + title: 'Only mentions and replies', + value: 'soft', + }, + { + title: 'Nothing', + value: 'hush', + }, +]; + +function NotificationsSheetContent({ + chatTitle, + onPressBack, +}: { + chatTitle?: string | null; + onPressBack: () => void; +}) { + const { updateVolume, group, channel } = useChatOptions(); + const { data: currentChannelVolume } = store.useChannelVolumeLevel( + channel?.id ?? '' + ); + const { data: currentGroupVolume } = store.useGroupVolumeLevel( + group?.id ?? '' + ); + const currentVolumeLevel = channel?.id + ? currentChannelVolume + : currentGroupVolume; + + const notificationActions = useMemo( + () => + createActionGroups([ + 'neutral', + ...notificationOptions.map( + ({ title, value }): Action => ({ + title, + accent: currentVolumeLevel === value ? 'positive' : 'neutral', + action: () => updateVolume(value), + endIcon: currentVolumeLevel === value ? 'Checkmark' : undefined, + }) + ), + ]), + [currentVolumeLevel, updateVolume] + ); + return ( + } + /> + ); +} + +function SheetBackButton({ onPress }: { onPress: () => void }) { + return ( + + + + ); +} diff --git a/packages/ui/src/components/ContactBook.tsx b/packages/ui/src/components/ContactBook.tsx index 047a2b485f..9715206e3f 100644 --- a/packages/ui/src/components/ContactBook.tsx +++ b/packages/ui/src/components/ContactBook.tsx @@ -185,7 +185,11 @@ export function ContactBook({ debounceTime={100} onChangeQuery={setQuery} placeholder={searchPlaceholder ?? ''} - inputProps={{ spellCheck: false }} + inputProps={{ + spellCheck: false, + autoCapitalize: 'none', + autoComplete: 'off', + }} /> )} diff --git a/packages/ui/src/components/ContactNameV2.tsx b/packages/ui/src/components/ContactNameV2.tsx index 5e63117657..ce2b0c1ab0 100644 --- a/packages/ui/src/components/ContactNameV2.tsx +++ b/packages/ui/src/components/ContactNameV2.tsx @@ -3,7 +3,7 @@ import React, { useMemo } from 'react'; import { useCalm } from '../contexts'; import { useContact } from '../contexts/appDataContext'; import { formatUserId } from '../utils/user'; -import { Text } from './TextV2'; +import { RawText } from './TextV2'; // This file is temporary -- it uses the new text, and I want to make sure it works across all callsites before swapping it in @@ -57,7 +57,7 @@ export const useContactName = (options: string | ContactNameOptions) => { return useContactNameProps(resolvedOptions).children; }; -const BaseContactName = Text.styleable<{ +const BaseContactName = RawText.styleable<{ contactId: string; expandLongIds?: boolean; mode?: 'contactId' | 'nickname' | 'both' | 'auto'; @@ -83,10 +83,10 @@ const BaseContactName = Text.styleable<{ } return ( - + > ); }, { diff --git a/packages/ui/src/components/ContentReference/ContentReference.tsx b/packages/ui/src/components/ContentReference/ContentReference.tsx index f0d9b777ca..d3057fe8a3 100644 --- a/packages/ui/src/components/ContentReference/ContentReference.tsx +++ b/packages/ui/src/components/ContentReference/ContentReference.tsx @@ -8,6 +8,7 @@ import { View, XStack, styled } from 'tamagui'; import { useNavigation } from '../../contexts'; import { useRequests } from '../../contexts/requests'; +import { useGroupTitle } from '../../utils'; import { ContactAvatar, GroupAvatar } from '../Avatar'; import { useContactName } from '../ContactNameV2'; import { GalleryContentRenderer } from '../GalleryPost'; @@ -283,6 +284,7 @@ export function GroupReference({ data, ...props }: { data?: db.Group | null } & ReferenceProps) { + const title = useGroupTitle(data); return ( @@ -310,7 +312,7 @@ export function GroupReference({ textAlign="center" paddingHorizontal="$m" > - {data.title} + {title} ) @@ -318,7 +320,7 @@ export function GroupReference({ - {data.title ?? data.id} + {title} {data.description} diff --git a/packages/ui/src/components/CreateGroupView.tsx b/packages/ui/src/components/CreateGroupView.tsx deleted file mode 100644 index 124c8e95d2..0000000000 --- a/packages/ui/src/components/CreateGroupView.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import * as db from '@tloncorp/shared/db'; -import { useCallback, useState } from 'react'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { View, YStack } from 'tamagui'; - -import { CreateGroupWidget } from './AddChats'; -import { Button } from './Button'; -import { TextButton } from './Buttons'; -import { ContactBook } from './ContactBook'; -import { ScreenHeader } from './ScreenHeader'; - -type screen = 'InviteUsers' | 'CreateGroup'; - -export function CreateGroupView({ - goBack, - navigateToChannel, -}: { - goBack: () => void; - navigateToChannel: (channel: db.Channel) => void; -}) { - const { bottom } = useSafeAreaInsets(); - const [screen, setScreen] = useState('InviteUsers'); - const [invitees, setInvitees] = useState([]); - - const handleCreatedGroup = useCallback( - ({ channel }: { channel: db.Channel }) => { - navigateToChannel(channel); - }, - [navigateToChannel] - ); - - return ( - - - screen === 'InviteUsers' ? goBack() : setScreen('InviteUsers') - } - showSessionStatus={false} - rightControls={ - screen === 'InviteUsers' ? ( - { - setInvitees([]); - setScreen('CreateGroup'); - }} - > - Skip - - ) : null - } - /> - {screen === 'InviteUsers' ? ( - - - - - - ) : ( - - )} - - ); -} diff --git a/packages/ui/src/components/DetailView.tsx b/packages/ui/src/components/DetailView.tsx index 67ca80aa24..34ffb293db 100644 --- a/packages/ui/src/components/DetailView.tsx +++ b/packages/ui/src/components/DetailView.tsx @@ -20,7 +20,7 @@ export interface DetailViewProps { posts?: db.Post[]; onPressImage?: (post: db.Post, imageUri?: string) => void; goBack?: () => void; - onPressRetry: (post: db.Post) => void; + onPressRetry?: (post: db.Post) => Promise; onPressDelete: (post: db.Post) => void; setActiveMessage: (post: db.Post | null) => void; activeMessage: db.Post | null; diff --git a/packages/ui/src/components/EditProfileScreenView.tsx b/packages/ui/src/components/EditProfileScreenView.tsx index cdf65c6f8d..2fca79c465 100644 --- a/packages/ui/src/components/EditProfileScreenView.tsx +++ b/packages/ui/src/components/EditProfileScreenView.tsx @@ -1,12 +1,11 @@ import * as db from '@tloncorp/shared/db'; -import { useStore } from '@tloncorp/ui'; import { useCallback, useMemo, useState } from 'react'; import { useForm } from 'react-hook-form'; import { Alert } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { ScrollView, View, XStack } from 'tamagui'; -import { useContact, useCurrentUserId } from '../contexts'; +import { useContact, useCurrentUserId, useStore } from '../contexts'; import { SigilAvatar } from './Avatar'; import { FavoriteGroupsDisplay } from './FavoriteGroupsDisplay'; import { @@ -273,7 +272,7 @@ export function EditProfileScreenView(props: Props) { {}} - backgroundColor="$secondaryBackground" + itemProps={{ backgroundColor: '$secondaryBackground' }} /> )} diff --git a/packages/ui/src/components/FavoriteGroupsDisplay.tsx b/packages/ui/src/components/FavoriteGroupsDisplay.tsx index bd7682cbbd..aacf789299 100644 --- a/packages/ui/src/components/FavoriteGroupsDisplay.tsx +++ b/packages/ui/src/components/FavoriteGroupsDisplay.tsx @@ -17,11 +17,9 @@ export function FavoriteGroupsDisplay(props: { // if editable, get sorted groups to pass to the selector const allGroups = useGroups(); - const titledGroups = useMemo(() => { - return allGroups?.filter((g) => !!g.title) ?? []; - }, [allGroups]); + const alphaSegmentedGroups = useAlphabeticallySegmentedGroups({ - groups: titledGroups, + groups: allGroups ?? [], enabled: true, }); diff --git a/packages/ui/src/components/FileDrop.native.tsx b/packages/ui/src/components/FileDrop.native.tsx new file mode 100644 index 0000000000..38c486ed79 --- /dev/null +++ b/packages/ui/src/components/FileDrop.native.tsx @@ -0,0 +1,11 @@ +import { ImagePickerAsset } from 'expo-image-picker'; +import { ComponentProps } from 'react'; +import { View } from 'tamagui'; + +export function FileDrop( + props: { + onAssetsDropped: (files: ImagePickerAsset[]) => void; + } & ComponentProps +) { + return ; +} diff --git a/packages/ui/src/components/FileDrop.tsx b/packages/ui/src/components/FileDrop.tsx new file mode 100644 index 0000000000..3d5c3f7bb9 --- /dev/null +++ b/packages/ui/src/components/FileDrop.tsx @@ -0,0 +1,106 @@ +import { ImagePickerAsset } from 'expo-image-picker'; +import { ComponentProps, useCallback } from 'react'; +import { useDropzone } from 'react-dropzone'; +import { View } from 'tamagui'; + +export function FileDrop({ + onAssetsDropped, + children, + ...props +}: { + onAssetsDropped: (files: ImagePickerAsset[]) => void; +} & ComponentProps) { + const handleDrop = useCallback( + async (files: File[]) => { + const measuredFiles = ( + await Promise.all( + files.map(async (file) => { + try { + return await measureFile(file); + } catch (e) { + console.error('Error measuring file', e); + return null; + } + }) + ) + ).filter((f) => f !== null) as { + uri: string; + width: number; + height: number; + file: File; + }[]; + onAssetsDropped(measuredFiles); + }, + [onAssetsDropped] + ); + + const { getInputProps, getRootProps } = useDropzone({ + onDrop: handleDrop, + noClick: true, + accept: { + 'image/*': [], + 'video/*': [], + }, + }); + + return ( + // @ts-expect-error reason: getRootProps() which is web specific return some react-native incompatible props, but it's fine + + {/* need an empty input div just have image drop feature in the web */} + {/* @ts-expect-error web-only props */} + + {children} + + ); +} + +async function measureFile(f: File) { + return { + file: f, + ...(f.type.startsWith('image') + ? await getImageAsset(f) + : await getVideoAsset(f)), + }; +} + +function getImageAsset( + file: File +): Promise<{ uri: string; width: number; height: number }> { + return new Promise((resolve, reject) => { + const img = new Image(); + const objectUrl = URL.createObjectURL(file); + img.onerror = (e) => { + reject(e); + }; + img.onload = function () { + resolve({ uri: objectUrl, width: img.width, height: img.height }); + }; + img.src = objectUrl; + }); +} + +function getVideoAsset( + file: File +): Promise<{ uri: string; width: number; height: number }> { + return new Promise((resolve, reject) => { + const video = document.createElement('video'); + const objectUrl = URL.createObjectURL(file); + video.onerror = (e) => { + reject(e); + }; + video.onloadedmetadata = function () { + resolve({ + uri: objectUrl, + width: video.videoWidth, + height: video.videoHeight, + }); + }; + video.src = objectUrl; + }); +} diff --git a/packages/ui/src/components/FindGroupsView.tsx b/packages/ui/src/components/FindGroupsView.tsx deleted file mode 100644 index ac3c970ced..0000000000 --- a/packages/ui/src/components/FindGroupsView.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { useCallback } from 'react'; -import { Text, View, YStack } from 'tamagui'; - -import useIsWindowNarrow from '../hooks/useIsWindowNarrow'; -import { ContactBook } from './ContactBook'; -import { ScreenHeader } from './ScreenHeader'; - -export function FindGroupsView({ - goBack, - goToContactHostedGroups, -}: { - goBack: () => void; - goToContactHostedGroups: (params: { contactId: string }) => void; -}) { - const handleContactSelected = useCallback( - (contactId: string) => { - console.log('go to contact hosted groups', contactId); - goToContactHostedGroups({ contactId: contactId }); - }, - [goToContactHostedGroups] - ); - - const isWindowNarrow = useIsWindowNarrow(); - - return ( - - - } - /> - - ); -} - -const GroupJoinExplanation = () => ( - - On Tlon, people host groups. - Look for groups hosted by people above. - -); diff --git a/packages/ui/src/components/Form/inputs.tsx b/packages/ui/src/components/Form/inputs.tsx index 8542956ddf..2466db4f43 100644 --- a/packages/ui/src/components/Form/inputs.tsx +++ b/packages/ui/src/components/Form/inputs.tsx @@ -304,40 +304,41 @@ interface TextInputWithIconAndButtonProps } export const TextInputWithIconAndButton = React.memo( - function TextInputWithIconAndButtonRaw({ - icon, - buttonText, - onButtonPress, - ...textInputProps - }: TextInputWithIconAndButtonProps) { - return ( - - - - - - ); - } + + + + + ); + } + ) ); // Toggle group diff --git a/packages/ui/src/components/GalleryPost/GalleryPost.tsx b/packages/ui/src/components/GalleryPost/GalleryPost.tsx index d77d0012b6..dc6009756c 100644 --- a/packages/ui/src/components/GalleryPost/GalleryPost.tsx +++ b/packages/ui/src/components/GalleryPost/GalleryPost.tsx @@ -42,7 +42,7 @@ export function GalleryPost({ post: db.Post; onPress?: (post: db.Post) => void; onLongPress?: (post: db.Post) => void; - onPressRetry?: (post: db.Post) => void; + onPressRetry?: (post: db.Post) => Promise; onPressDelete?: (post: db.Post) => void; showAuthor?: boolean; isHighlighted?: boolean; diff --git a/packages/ui/src/components/GlobalSearch/index.tsx b/packages/ui/src/components/GlobalSearch/index.tsx new file mode 100644 index 0000000000..8f74e09975 --- /dev/null +++ b/packages/ui/src/components/GlobalSearch/index.tsx @@ -0,0 +1,384 @@ +import { FlashList, ListRenderItem } from '@shopify/flash-list'; +import type * as db from '@tloncorp/shared/db'; +import * as store from '@tloncorp/shared/store'; +import { + ChatListItem, + ChatListItemData, + LoadingSpinner, + SectionListHeader, + TabName, + Text, + TextInputWithIconAndButton, + View, + XStack, + YStack, + getChatKey, + getItemType, + isSectionHeader, + useFilteredChats, +} from '@tloncorp/ui'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + LayoutChangeEvent, + NativeSyntheticEvent, + TextInput, + TextInputKeyPressEventData, +} from 'react-native'; +import { getTokenValue } from 'tamagui'; + +import { useGlobalSearch } from '../../contexts/globalSearch'; + +export interface GlobalSearchProps { + navigateToGroup: (id: string) => void; + navigateToChannel: (channel: db.Channel) => void; +} + +export function GlobalSearch({ + navigateToGroup, + navigateToChannel, +}: GlobalSearchProps) { + const { isOpen, setIsOpen } = useGlobalSearch(); + const [searchQuery, setSearchQuery] = useState(''); + const [selectedIndex, setSelectedIndex] = useState(0); + const inputRef = useRef(null); + const listRef = useRef>(null); + const groupsQuery = store.useGroups({}); + const contactsQuery = store.useContacts(); + + const isLoading = groupsQuery.isLoading || contactsQuery.isLoading; + const hasError = groupsQuery.error || contactsQuery.error; + + const { data: chats } = store.useCurrentChats({ + enabled: isOpen, + }); + + const resolvedChats = useMemo(() => { + return { + pinned: chats?.pinned ?? [], + unpinned: chats?.unpinned ?? [], + }; + }, [chats]); + + const filteredChatsConfig = useMemo( + () => ({ + pinned: resolvedChats.pinned, + unpinned: resolvedChats.unpinned, + pending: [], + searchQuery, + activeTab: 'all' as TabName, + }), + [resolvedChats, searchQuery, selectedIndex] // We need to include selectedIndex to trigger re-render when it changes + ); + + const displayData = useFilteredChats(filteredChatsConfig); + + const listItems: ChatListItemData[] = useMemo( + () => + displayData.flatMap((section) => { + return [ + { title: section.title, type: 'sectionHeader' }, + ...section.data, + ]; + }), + [displayData] + ); + + // Find first non-header item when search query changes + // Only reset selection when search query changes + useEffect(() => { + const firstItemIndex = listItems.findIndex( + (item) => !isSectionHeader(item) + ); + if (firstItemIndex >= 0) { + setSelectedIndex(firstItemIndex); + } + }, [searchQuery]); // Only run when search query changes + + const sizeRefs = useRef({ + sectionHeader: 28, + chatListItem: 72, + }); + + const handleHeaderLayout = useCallback((e: LayoutChangeEvent) => { + sizeRefs.current.sectionHeader = e.nativeEvent.layout.height; + }, []); + + const handleItemLayout = useCallback((e: LayoutChangeEvent) => { + sizeRefs.current.chatListItem = e.nativeEvent.layout.height; + }, []); + + const handleOverrideLayout = useCallback( + (layout: { span?: number; size?: number }, item: ChatListItemData) => { + layout.size = isSectionHeader(item) + ? sizeRefs.current.sectionHeader + : sizeRefs.current.chatListItem; + }, + [] + ); + + const onPressItem = useCallback( + async (item: db.Chat) => { + if (item.type === 'group') { + navigateToGroup(item.group.id); + } else { + navigateToChannel(item.channel); + } + + setIsOpen(false); + }, + [navigateToGroup, navigateToChannel, setIsOpen] + ); + + const renderItem: ListRenderItem = useCallback( + ({ item }) => { + if (isSectionHeader(item)) { + return ( + + {item.title} + + ); + } else { + return ( + + ); + } + }, + [ + handleHeaderLayout, + onPressItem, + handleItemLayout, + listItems, + selectedIndex, + ] + ); + + const handleNavigationKey = useCallback( + (key: string) => { + let nextIndex = selectedIndex; + const selectedItem = listItems[selectedIndex]; + + switch (key) { + case 'ArrowDown': + nextIndex = selectedIndex + 1; + while ( + nextIndex < listItems.length && + isSectionHeader(listItems[nextIndex]) + ) { + nextIndex++; + } + if (nextIndex < listItems.length) { + setSelectedIndex(nextIndex); + listRef.current?.scrollToIndex({ index: nextIndex, animated: true }); + } + break; + case 'ArrowUp': + nextIndex = selectedIndex - 1; + while (nextIndex >= 0 && isSectionHeader(listItems[nextIndex])) { + nextIndex--; + } + if (nextIndex >= 0) { + setSelectedIndex(nextIndex); + listRef.current?.scrollToIndex({ index: nextIndex, animated: true }); + } + break; + case 'Escape': + setIsOpen(false); + break; + case 'Enter': + if (selectedItem && !isSectionHeader(selectedItem)) { + onPressItem(selectedItem); + } + break; + } + }, + [selectedIndex, listItems, onPressItem, setIsOpen] + ); + + const contentContainerStyle = useMemo( + () => ({ + padding: getTokenValue('$l', 'size'), + paddingBottom: 100, // bottom nav height + some cushion + }), + [] + ); + + const handleKeyPress = useCallback( + (e: NativeSyntheticEvent) => { + const key = e.nativeEvent.key; + const metaKey = (e.nativeEvent as any).metaKey; + const ctrlKey = (e.nativeEvent as any).ctrlKey; + + if ((metaKey || ctrlKey) && key.toLowerCase() === 'k') { + e.preventDefault(); + setIsOpen(false); + } else if ( + key === 'ArrowDown' || + key === 'ArrowUp' || + key === 'Enter' || + key === 'Escape' + ) { + e.preventDefault(); + handleNavigationKey(key); + } + }, + [handleNavigationKey, setIsOpen] + ); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') { + event.preventDefault(); + setIsOpen(!isOpen); + } else if (event.key === 'Escape') { + event.preventDefault(); + setIsOpen(false); + } else if (isOpen) { + // Handle navigation keys + switch (event.key) { + case 'ArrowDown': + case 'ArrowUp': + case 'Enter': + event.preventDefault(); + handleNavigationKey(event.key); + break; + } + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [isOpen, handleNavigationKey, setIsOpen]); + + useEffect(() => { + if (isOpen) { + inputRef.current?.focus(); + setSearchQuery(''); + } + }, [isOpen]); + + if (!isOpen) return null; + + return ( + <> + { + setIsOpen(false); + }} + style={{ + position: 'fixed', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + zIndex: 50, + }} + /> + + + setIsOpen(false)} + buttonText="Close" + spellCheck={false} + autoCorrect={false} + autoCapitalize="none" + /> + + {isLoading ? ( + + + + ) : hasError ? ( + + Error loading results. Please try again. + + ) : searchQuery !== '' && !displayData[0]?.data.length ? ( + + No results found + + ) : ( + + )} + + + + + + ↑↓ + + + to navigate + + + + + enter + + + to select + + + + + esc + + + or + + + {navigator.platform.includes('Mac') ? '⌘K' : 'Ctrl+K'} + + + to close + + + + + + ); +} diff --git a/packages/ui/src/components/GroupChannelsScreenView.tsx b/packages/ui/src/components/GroupChannelsScreenView.tsx index 7b61f2102a..fdccd80af5 100644 --- a/packages/ui/src/components/GroupChannelsScreenView.tsx +++ b/packages/ui/src/components/GroupChannelsScreenView.tsx @@ -4,7 +4,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { ScrollView, View, YStack, getVariableValue, useTheme } from 'tamagui'; import { useChatOptions, useCurrentUserId } from '../contexts'; -import { useIsAdmin } from '../utils/channelUtils'; +import { useGroupTitle, useIsAdmin } from '../utils/channelUtils'; import { Badge } from './Badge'; import ChannelNavSections from './ChannelNavSections'; import { ChannelListItem } from './ListItem/ChannelListItem'; @@ -52,7 +52,7 @@ export function GroupChannelsScreenView({ [group, chatOptions] ); - const title = group ? group?.title ?? 'Untitled' : ''; + const title = useGroupTitle(group); const titleWidth = useCallback(() => { if (isGroupAdmin) { diff --git a/packages/ui/src/components/GroupPreviewSheet.tsx b/packages/ui/src/components/GroupPreviewSheet.tsx index e4c16b48e4..7e2027af3b 100644 --- a/packages/ui/src/components/GroupPreviewSheet.tsx +++ b/packages/ui/src/components/GroupPreviewSheet.tsx @@ -2,7 +2,7 @@ import * as db from '@tloncorp/shared/db'; import * as store from '@tloncorp/shared/store'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { triggerHaptic } from '../utils'; +import { triggerHaptic, useGroupTitle } from '../utils'; import { ActionGroup, ActionSheet, @@ -146,10 +146,12 @@ export function GroupPreviewPane({ } }, [isJoining, group.id, onActionComplete]); + const title = useGroupTitle(group); + return ( <> } /> diff --git a/packages/ui/src/components/InviteFriendsToTlonButton.tsx b/packages/ui/src/components/InviteFriendsToTlonButton.tsx index baa7669a79..dcb5a13b84 100644 --- a/packages/ui/src/components/InviteFriendsToTlonButton.tsx +++ b/packages/ui/src/components/InviteFriendsToTlonButton.tsx @@ -1,20 +1,24 @@ import { AnalyticsEvent, createDevLogger } from '@tloncorp/shared'; import * as db from '@tloncorp/shared/db'; import * as store from '@tloncorp/shared/store'; -import { useCallback, useEffect } from 'react'; +import { ComponentProps, useCallback, useEffect } from 'react'; import { Share } from 'react-native'; -import { Text, View, XStack, isWeb } from 'tamagui'; +import { isWeb } from 'tamagui'; import { useCurrentUserId, useInviteService } from '../contexts'; import { useCopy } from '../hooks/useCopy'; -import { useIsAdmin } from '../utils'; +import { useGroupTitle, useIsAdmin } from '../utils'; import { Button } from './Button'; import { Icon } from './Icon'; import { LoadingSpinner } from './LoadingSpinner'; +import { Text } from './TextV2'; const logger = createDevLogger('InviteButton', true); -export function InviteFriendsToTlonButton({ group }: { group?: db.Group }) { +export function InviteFriendsToTlonButton({ + group, + ...props +}: { group?: db.Group } & Omit, 'group'>) { const userId = useCurrentUserId(); const isGroupAdmin = useIsAdmin(group?.id ?? '', userId); const inviteService = useInviteService(); @@ -23,14 +27,19 @@ export function InviteFriendsToTlonButton({ group }: { group?: db.Group }) { inviteServiceEndpoint: inviteService.endpoint, inviteServiceIsDev: inviteService.isDev, }); + const title = useGroupTitle(group); const { doCopy } = useCopy(shareUrl || ''); const handleInviteButtonPress = useCallback(async () => { if (shareUrl && status === 'ready' && group) { if (isWeb) { + logger.trackEvent(AnalyticsEvent.InviteShared, { + inviteId: shareUrl.split('/').pop() ?? null, + inviteType: 'group', + }); if (navigator.share !== undefined) { await navigator.share({ - title: `Join ${group.title} on Tlon`, + title: `Join ${title} on Tlon`, url: shareUrl, }); return; @@ -42,13 +51,14 @@ export function InviteFriendsToTlonButton({ group }: { group?: db.Group }) { try { const result = await Share.share({ - message: `Join ${group.title} on Tlon: ${shareUrl}`, - title: `Join ${group.title} on Tlon`, + message: `Join ${title} on Tlon: ${shareUrl}`, + title: `Join ${title} on Tlon`, }); if (result.action === Share.sharedAction) { logger.trackEvent(AnalyticsEvent.InviteShared, { inviteId: shareUrl.split('/').pop() ?? null, + inviteType: 'group', }); } } catch (error) { @@ -56,7 +66,7 @@ export function InviteFriendsToTlonButton({ group }: { group?: db.Group }) { } return; } - }, [shareUrl, status, group, doCopy]); + }, [shareUrl, status, group, doCopy, title]); useEffect(() => { const toggleLink = async () => { @@ -75,43 +85,44 @@ export function InviteFriendsToTlonButton({ group }: { group?: db.Group }) { (group?.privacy === 'private' || group?.privacy === 'secret') && !isGroupAdmin ) { - return Only administrators may invite people to this group.; + return ( + + Only administrators may invite people to this group. + + ); } + const linkIsLoading = status === 'loading' || status === 'stale'; + const linkIsReady = status === 'ready' && typeof shareUrl === 'string'; + const linkIsDisabled = status === 'disabled'; + const linkFailed = + linkIsDisabled || status === 'error' || status === 'unsupported'; + return ( ); } diff --git a/packages/ui/src/components/ListItem/ChannelListItem.tsx b/packages/ui/src/components/ListItem/ChannelListItem.tsx index 4614e435a0..78341fb898 100644 --- a/packages/ui/src/components/ListItem/ChannelListItem.tsx +++ b/packages/ui/src/components/ListItem/ChannelListItem.tsx @@ -43,7 +43,7 @@ export function ChannelListItem({ if (model.type === 'dm' || model.type === 'groupDm') { return { subtitle: [ - firstMemberId, + utils.formatUserId(firstMemberId)?.display, memberCount > 2 && `and ${memberCount - 1} others`, ] .filter((v) => !!v) diff --git a/packages/ui/src/components/ListItem/GroupListItem.tsx b/packages/ui/src/components/ListItem/GroupListItem.tsx index 1258e56fad..7161c348d1 100644 --- a/packages/ui/src/components/ListItem/GroupListItem.tsx +++ b/packages/ui/src/components/ListItem/GroupListItem.tsx @@ -2,8 +2,10 @@ import type * as db from '@tloncorp/shared/db'; import * as logic from '@tloncorp/shared/logic'; import { View, isWeb } from 'tamagui'; +import { useGroupTitle } from '../../utils'; import { Badge } from '../Badge'; import { Button } from '../Button'; +import { ContactName } from '../ContactNameV2'; import { Icon } from '../Icon'; import Pressable from '../Pressable'; import type { ListItemProps } from './ListItem'; @@ -18,7 +20,7 @@ export const GroupListItem = ({ ...props }: { customSubtitle?: string } & ListItemProps) => { const unreadCount = model.unread?.count ?? 0; - const title = model.title ?? model.id; + const title = useGroupTitle(model); const { isPending, label: statusLabel, isErrored } = getGroupStatus(model); const handlePress = logic.useMutableCallback(() => { @@ -29,6 +31,8 @@ export const GroupListItem = ({ onLongPress?.(model); }); + const isSingleChannel = model.channels?.length === 1; + return ( {title} - {customSubtitle && ( + {customSubtitle ? ( {customSubtitle} - )} - {model.lastPost && model.channels?.length && !customSubtitle && ( + ) : isSingleChannel ? ( + + Group + + ) : model.lastPost ? ( - {model.channels[0].title} + {(model.channels?.length ?? 0) > 1 + ? model.channels?.[0]?.title + : 'Group'} - )} - {!isPending && model.lastPost ? ( + ) : isPending && model.hostUserId ? ( + <> + + Group invitation + + + Hosted by + + + ) : null} + {model.lastPost ? ( + ) : !isPending ? ( + No posts yet ) : null} diff --git a/packages/ui/src/components/ListItem/ListItem.tsx b/packages/ui/src/components/ListItem/ListItem.tsx index 136960e6d8..62e7bda5df 100644 --- a/packages/ui/src/components/ListItem/ListItem.tsx +++ b/packages/ui/src/components/ListItem/ListItem.tsx @@ -45,6 +45,7 @@ export const ListItemFrame = styled(XStack, { justifyContent: 'space-between', alignItems: 'stretch', backgroundColor: '$transparent', + userSelect: 'none', height: '$6xl', }); diff --git a/packages/ui/src/components/ManageChannels/CreateChannelSheet.tsx b/packages/ui/src/components/ManageChannels/CreateChannelSheet.tsx index 7b4f109fae..efaf62c729 100644 --- a/packages/ui/src/components/ManageChannels/CreateChannelSheet.tsx +++ b/packages/ui/src/components/ManageChannels/CreateChannelSheet.tsx @@ -3,7 +3,7 @@ import { CollectionRendererId, DraftInputId, PostContentRendererId, - useCreateChannel, + createChannel, } from '@tloncorp/shared'; import * as db from '@tloncorp/shared/db'; import { @@ -16,7 +16,6 @@ import { } from 'react'; import { useForm } from 'react-hook-form'; -import { useCurrentUserId } from '../../contexts'; import { ActionSheet } from '../ActionSheet'; import { Button } from '../Button'; import * as Form from '../Form'; @@ -71,27 +70,20 @@ export function CreateChannelSheet({ }, }); - const currentUserId = useCurrentUserId(); - const createChannel = useCreateChannel({ - group, - currentUserId, - }); const handlePressSave = useCallback( async (data: { title: string; channelType: ChannelTypeName }) => { - let contentConfiguration: ChannelContentConfiguration | undefined; - if (data.channelType === 'custom') { - contentConfiguration = customChannelConfigRef.current?.getFormValue(); - // HACK: We don't have a custom channel type yet, so call it a chat - data.channelType = 'chat'; - } createChannel({ + groupId: group.id, title: data.title, channelType: data.channelType, - contentConfiguration, + contentConfiguration: + data.channelType === 'custom' + ? customChannelConfigRef.current?.getFormValue() + : undefined, }); onOpenChange(false); }, - [createChannel, onOpenChange] + [group.id, onOpenChange] ); const availableChannelTypes = useMemo( diff --git a/packages/ui/src/components/MessageInput/MessageInputBase.tsx b/packages/ui/src/components/MessageInput/MessageInputBase.tsx index ba41c8ac81..c4f49592e8 100644 --- a/packages/ui/src/components/MessageInput/MessageInputBase.tsx +++ b/packages/ui/src/components/MessageInput/MessageInputBase.tsx @@ -18,7 +18,6 @@ import { useAttachmentContext } from '../../contexts/attachment'; import { Button } from '../Button'; import { FloatingActionButton } from '../FloatingActionButton'; import { Icon } from '../Icon'; -import { LoadingSpinner } from '../LoadingSpinner'; import { GalleryDraftType } from '../draftInputs/shared'; import AttachmentButton from './AttachmentButton'; import InputMentionPopup from './InputMentionPopup'; @@ -188,11 +187,7 @@ export const MessageInputContainer = memo( borderColor="transparent" opacity={disableSend ? 0.5 : 1} > - {isSending ? ( - - - - ) : isEditing ? ( + {isEditing ? ( ) : ( diff --git a/packages/ui/src/components/MetaEditorScreenView.tsx b/packages/ui/src/components/MetaEditorScreenView.tsx index 1b3955f8e9..6ef7b2e659 100644 --- a/packages/ui/src/components/MetaEditorScreenView.tsx +++ b/packages/ui/src/components/MetaEditorScreenView.tsx @@ -8,10 +8,16 @@ import { useState, } from 'react'; import { useForm } from 'react-hook-form'; -import { View, YStack } from 'tamagui'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { ScrollView, View } from 'tamagui'; -import { EditablePofileImages } from './EditableProfileImages'; -import { FormInput } from './FormInput'; +import { capitalize } from '../utils'; +import { + ControlledImageField, + ControlledTextField, + ControlledTextareaField, + FormFrame, +} from './Form'; import KeyboardAvoidingView from './KeyboardAvoidingView'; import { ScreenHeader } from './ScreenHeader'; @@ -29,20 +35,13 @@ export function MetaEditorScreenView({ }>) { const [modelLoaded, setModelLoaded] = useState(!!chat); const defaultValues = useMemo(() => getMetaWithDefaults(chat), [chat]); - const { group, channel } = useMemo(() => { - if (chat) { - return logic.isGroup(chat) - ? { group: chat, channel: null } - : { group: null, channel: chat }; - } - return { group: null, channel: null }; - }, [chat]); + + const label = chat && logic.isGroup(chat) ? 'group' : 'channel'; const { control, handleSubmit, - formState: { errors }, - setValue, + formState: { isValid }, reset, } = useForm({ defaultValues, @@ -60,47 +59,83 @@ export function MetaEditorScreenView({ [handleSubmit, onSubmit] ); + const insets = useSafeAreaInsets(); + return ( - - - - Save - - } - /> - - - setValue('coverImage', url)} - onSetIconUrl={(url) => setValue('iconImage', url)} + + + Cancel + + } + rightControls={ + + Save + + } + /> + + + + + + - - - - {children} - - - - + {children} + + + ); } diff --git a/packages/ui/src/components/NotebookPost/NotebookPost.tsx b/packages/ui/src/components/NotebookPost/NotebookPost.tsx index c2f1c948f9..96f5c427e1 100644 --- a/packages/ui/src/components/NotebookPost/NotebookPost.tsx +++ b/packages/ui/src/components/NotebookPost/NotebookPost.tsx @@ -42,7 +42,7 @@ export function NotebookPost({ onPress?: (post: db.Post) => void; onLongPress?: (post: db.Post) => void; onPressImage?: (post: db.Post, imageUri?: string) => void; - onPressRetry?: (post: db.Post) => void; + onPressRetry?: (post: db.Post) => Promise; onPressDelete?: (post: db.Post) => void; detailView?: boolean; showReplies?: boolean; diff --git a/packages/ui/src/components/Onboarding/OnboardingInvite.tsx b/packages/ui/src/components/Onboarding/OnboardingInvite.tsx index 7dbd1b3594..617f29011e 100644 --- a/packages/ui/src/components/Onboarding/OnboardingInvite.tsx +++ b/packages/ui/src/components/Onboarding/OnboardingInvite.tsx @@ -1,9 +1,18 @@ import { DeepLinkMetadata } from '@tloncorp/shared'; +import * as db from '@tloncorp/shared/db'; import React, { ComponentProps } from 'react'; import { AppDataContextProvider } from '../../contexts'; +import { getDisplayName } from '../../utils'; import { ListItem } from '../ListItem'; +interface GroupShim { + id: string; + title: string | undefined; + iconImage: string | undefined; + iconImageColor: string | undefined; +} + export const OnboardingInviteBlock = React.memo(function OnboardingInviteBlock({ metadata, ...rest @@ -12,22 +21,76 @@ export const OnboardingInviteBlock = React.memo(function OnboardingInviteBlock({ inviterUserId, invitedGroupId, inviterNickname, + inviterAvatarImage, + inviterColor, invitedGroupTitle, invitedGroupIconImageUrl, invitedGroupiconImageColor, + inviteType, } = metadata; - if (!inviterUserId || !invitedGroupId) { + const isValidUserInvite = inviterUserId && inviteType === 'user'; + const isValidGroupInvite = inviterUserId && invitedGroupId; + + if (!isValidUserInvite && !isValidGroupInvite) { return null; } + const inviter = { + id: inviterUserId!, + nickname: inviterNickname, + avatarImage: inviterAvatarImage, + color: inviterColor, + } as db.Contact; + const groupShim = { id: invitedGroupId, title: invitedGroupTitle, iconImage: invitedGroupIconImageUrl, iconImageColor: invitedGroupiconImageColor, - }; + } as GroupShim; + + if (inviteType === 'user') { + return ; + } + + return ; +}); +function UserInvite({ + inviter, + ...rest +}: { inviter: db.Contact } & ComponentProps) { + return ( + // provider needed to support calm settings usage down the tree + + + + + {getDisplayName(inviter)} + Sent you a personal invite + + + + ); +} + +function GroupInvite({ + groupShim, + inviter, + ...rest +}: { groupShim: GroupShim; inviter: db.Contact } & ComponentProps< + typeof ListItem +>) { return ( // provider needed to support calm settings usage down the tree @@ -39,20 +102,18 @@ export const OnboardingInviteBlock = React.memo(function OnboardingInviteBlock({ {...rest} > - Join {invitedGroupTitle ?? invitedGroupId} + Join {groupShim.title ?? groupShim.id} - Invited by {inviterNickname ?? inviterUserId} + Invited by {inviter.nickname ?? inviter.id} ); -}); +} diff --git a/packages/ui/src/components/PersonalInviteButton.tsx b/packages/ui/src/components/PersonalInviteButton.tsx new file mode 100644 index 0000000000..e082cfd0c1 --- /dev/null +++ b/packages/ui/src/components/PersonalInviteButton.tsx @@ -0,0 +1,74 @@ +import { AnalyticsEvent, createDevLogger } from '@tloncorp/shared'; +import * as db from '@tloncorp/shared/db'; +import { useCallback, useMemo } from 'react'; +import { Share } from 'react-native'; +import { isWeb } from 'tamagui'; + +import { useContact, useCurrentUserId } from '../contexts'; +import { useCopy } from '../hooks/useCopy'; +import { getDisplayName } from '../utils'; +import { Button } from './Button'; +import { Icon } from './Icon'; +import { Text } from './TextV2'; + +const logger = createDevLogger('PersonalInviteButton', true); + +export function PersonalInviteButton() { + const currentUserId = useCurrentUserId(); + const userContact = useContact(currentUserId); + // must be pre-populated before rendering this component + const inviteLink = db.personalInviteLink.useValue() as string; + const { doCopy } = useCopy(inviteLink); + + const userDisplayName = useMemo( + () => (userContact ? getDisplayName(userContact) : currentUserId), + [userContact, currentUserId] + ); + + const handleInviteButtonPress = useCallback(async () => { + if (isWeb) { + if (navigator.share !== undefined) { + logger.trackEvent(AnalyticsEvent.InviteShared, { + inviteId: inviteLink.split('/').pop() ?? null, + inviteType: 'personal', + }); + await navigator.share({ + title: `${userDisplayName} invited you to TM`, + url: inviteLink, + }); + return; + } + + doCopy(); + return; + } + + try { + const result = await Share.share({ + message: `${userDisplayName} invited you to TM: ${inviteLink}`, + title: `${userDisplayName} invited you to TM`, + }); + + if (result.action === Share.sharedAction) { + logger.trackEvent(AnalyticsEvent.InviteShared, { + inviteId: inviteLink.split('/').pop() ?? null, + inviteType: 'personal', + }); + } + } catch (error) { + console.error('Error sharing:', error); + } + return; + }, [doCopy, inviteLink, userDisplayName]); + + return ( + + ); +} diff --git a/packages/ui/src/components/PersonalInviteSheet.tsx b/packages/ui/src/components/PersonalInviteSheet.tsx new file mode 100644 index 0000000000..d37deccbbc --- /dev/null +++ b/packages/ui/src/components/PersonalInviteSheet.tsx @@ -0,0 +1,46 @@ +import * as db from '@tloncorp/shared/db'; +import QRCode from 'react-qr-code'; +import { View, useTheme } from 'tamagui'; + +import { ActionSheet } from './ActionSheet'; +import { PersonalInviteButton } from './PersonalInviteButton'; +import { Text } from './TextV2'; + +export function PersonalInviteSheet({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const inviteLink = db.personalInviteLink.useValue(); + const theme = useTheme(); + + return ( + + + + + Anyone you invite will skip the waitlist and be added to your + contacts. You'll receive a DM when they join. + + + {inviteLink && ( + + )} + + + + + ); +} diff --git a/packages/ui/src/components/PostContent/BlockRenderer.tsx b/packages/ui/src/components/PostContent/BlockRenderer.tsx index 94dc26fa67..a351a44a42 100644 --- a/packages/ui/src/components/PostContent/BlockRenderer.tsx +++ b/packages/ui/src/components/PostContent/BlockRenderer.tsx @@ -126,6 +126,8 @@ export const LineText = styled(Text, { color: '$primaryText', size: '$body', context: cn.ContentContext, + userSelect: 'text', + cursor: 'text', variants: { isNotice: { true: { diff --git a/packages/ui/src/components/PostContent/ContentRenderer.tsx b/packages/ui/src/components/PostContent/ContentRenderer.tsx index 3f340a97b7..8f611e4fec 100644 --- a/packages/ui/src/components/PostContent/ContentRenderer.tsx +++ b/packages/ui/src/components/PostContent/ContentRenderer.tsx @@ -21,6 +21,7 @@ const ContentRendererFrame = styled(YStack, { name: 'ContentFrame', context: ContentContext, width: '100%', + userSelect: 'text', }); // Renderers diff --git a/packages/ui/src/components/PostScreenView.tsx b/packages/ui/src/components/PostScreenView.tsx index 5ad97b1163..343471ee07 100644 --- a/packages/ui/src/components/PostScreenView.tsx +++ b/packages/ui/src/components/PostScreenView.tsx @@ -2,20 +2,23 @@ import { isChatChannel as getIsChatChannel } from '@tloncorp/shared'; import type * as db from '@tloncorp/shared/db'; import * as urbit from '@tloncorp/shared/urbit'; import { Story } from '@tloncorp/shared/urbit'; -import { ImagePickerAsset } from 'expo-image-picker'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { FlatList } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Text, View, YStack } from 'tamagui'; -import { NavigationProvider, useCurrentUserId } from '../contexts'; -import { AttachmentProvider } from '../contexts/attachment'; +import { + NavigationProvider, + useAttachmentContext, + useCurrentUserId, +} from '../contexts'; import * as utils from '../utils'; import BareChatInput from './BareChatInput'; import { BigInput } from './BigInput'; import { ChannelFooter } from './Channel/ChannelFooter'; import { ChannelHeader } from './Channel/ChannelHeader'; import { DetailView } from './DetailView'; +import { FileDrop } from './FileDrop'; import { GroupPreviewAction, GroupPreviewSheet } from './GroupPreviewSheet'; import KeyboardAvoidingView from './KeyboardAvoidingView'; import { TlonEditorBridge } from './MessageInput/toolbarActions.native'; @@ -30,7 +33,6 @@ export function PostScreenView({ markRead, goBack, groupMembers, - uploadAsset, handleGoToImage, handleGoToUserProfile, storeDraft, @@ -46,7 +48,6 @@ export function PostScreenView({ goToDm, negotiationMatch, headerMode, - canUpload, }: { channel: db.Channel; initialThreadUnread?: db.ThreadUnreadState | null; @@ -60,7 +61,6 @@ export function PostScreenView({ groupMembers: db.ChatMember[]; handleGoToImage?: (post: db.Post, uri?: string) => void; handleGoToUserProfile: (userId: string) => void; - uploadAsset: (asset: ImagePickerAsset) => Promise; storeDraft: (draft: urbit.JSONContent) => void; clearDraft: () => void; getDraft: () => Promise; @@ -72,14 +72,13 @@ export function PostScreenView({ parentId?: string, metadata?: db.PostMetadata ) => Promise; - onPressRetry: (post: db.Post) => void; + onPressRetry?: (post: db.Post) => Promise; onPressDelete: (post: db.Post) => void; onPressRef: (channel: db.Channel, post: db.Post) => void; onGroupAction: (action: GroupPreviewAction, group: db.Group) => void; goToDm: (participants: string[]) => void; negotiationMatch: boolean; headerMode: 'default' | 'next'; - canUpload: boolean; }) { const [activeMessage, setActiveMessage] = useState(null); const [inputShouldBlur, setInputShouldBlur] = useState(false); @@ -211,127 +210,131 @@ export function PostScreenView({ }; }, [channel.type, getDraft, storeDraft, clearDraft]); + const { attachAssets } = useAttachmentContext(); + return ( - - + - - - - - {parentPost ? ( - - ) : null} + + + + {parentPost ? ( + + ) : null} - {negotiationMatch && - channel && - canWrite && - !(isEditingParent && channel.type === 'notebook') && ( - - )} - {!negotiationMatch && channel && canWrite && ( - - - Your ship's version of the Tlon app doesn't match - the channel host. - - - )} - - {parentPost && - isEditingParent && - (channel.type === 'notebook' || channel.type === 'gallery') ? ( - {}} - getDraft={getDraft} - storeDraft={storeDraft} - clearDraft={clearDraft} + send={sendReply} + channelId={channel.id} groupMembers={groupMembers} - /> - ) : null} - {headerMode === 'next' && ( - )} - - setGroupPreview(null)} - onActionComplete={handleGroupAction} - /> - - - - + {!negotiationMatch && channel && canWrite && ( + + + Your ship's version of the Tlon app doesn't match + the channel host. + + + )} + + {parentPost && + isEditingParent && + (channel.type === 'notebook' || channel.type === 'gallery') ? ( + {}} + getDraft={getDraft} + storeDraft={storeDraft} + clearDraft={clearDraft} + groupMembers={groupMembers} + /> + ) : null} + {headerMode === 'next' && ( + + )} + + setGroupPreview(null)} + onActionComplete={handleGroupAction} + /> + + + ); } diff --git a/packages/ui/src/components/Pressable.tsx b/packages/ui/src/components/Pressable.tsx index 54ccf64cf8..892f43f848 100644 --- a/packages/ui/src/components/Pressable.tsx +++ b/packages/ui/src/components/Pressable.tsx @@ -79,6 +79,7 @@ export default function Pressable({ group onPress={onPressLink ?? onPress} onLongPress={longPressHandler} + cursor='pointer' // Pressable always blocks touches from bubbling to ancestors, even if // no handlers are attached. // To allow bubbling, disable the Pressable (mixin) when no handlers @@ -96,6 +97,7 @@ export default function Pressable({ onPress={onPress} onLongPress={longPressHandler} disabled={!hasInteractionHandler} + cursor='pointer' > {children} diff --git a/packages/ui/src/components/ProfileScreenView.tsx b/packages/ui/src/components/ProfileScreenView.tsx index c86345bda5..c336753b22 100644 --- a/packages/ui/src/components/ProfileScreenView.tsx +++ b/packages/ui/src/components/ProfileScreenView.tsx @@ -1,9 +1,7 @@ -import { useFeatureFlag } from 'posthog-react-native'; import { ReactElement } from 'react'; -import { Alert, Share } from 'react-native'; +import { Alert } from 'react-native'; import { ScrollView, View, YStack } from 'tamagui'; -import { ContactAvatar } from './Avatar'; import { IconType } from './Icon'; import { ListItem } from './ListItem'; import Pressable from './Pressable'; @@ -27,8 +25,6 @@ interface Props { } export function ProfileScreenView(props: Props) { - const showDmLure = useFeatureFlag('share-dm-lure'); - // TODO: Add logout back in when we figure out TLON-2098. const handleLogoutPressed = () => { Alert.alert('Log out', 'Are you sure you want to log out?', [ @@ -44,37 +40,11 @@ export function ProfileScreenView(props: Props) { ]); }; - const onShare = async () => { - try { - await Share.share( - { - message: - 'I’m inviting you to Tlon, the only communication tool you can trust.', - url: props.dmLink, - title: 'Join me on Tlon', - }, - { - subject: 'Join me on Tlon', - } - ); - } catch (error) { - console.error(error.message); - } - }; - return ( <> - {showDmLure && props.dmLink !== '' && ( - - )} diff --git a/packages/ui/src/components/ScreenHeader.tsx b/packages/ui/src/components/ScreenHeader.tsx index 31a70cf672..90907dd9b6 100644 --- a/packages/ui/src/components/ScreenHeader.tsx +++ b/packages/ui/src/components/ScreenHeader.tsx @@ -75,6 +75,7 @@ export const ScreenHeaderComponent = ({ const HeaderIconButton = styled(Icon, { customSize: ['$3xl', '$2xl'], borderRadius: '$m', + cursor: 'pointer', pressStyle: { opacity: 0.5, }, diff --git a/packages/ui/src/components/UserProfileScreenView.tsx b/packages/ui/src/components/UserProfileScreenView.tsx index 8898f305b3..2344235021 100644 --- a/packages/ui/src/components/UserProfileScreenView.tsx +++ b/packages/ui/src/components/UserProfileScreenView.tsx @@ -16,16 +16,18 @@ import { XStack, YStack, styled, + useTheme, useWindowDimensions, } from 'tamagui'; import { useContact, useCurrentUserId, useNavigation } from '../contexts'; import { useCopy } from '../hooks/useCopy'; -import { triggerHaptic } from '../utils'; +import { triggerHaptic, useGroupTitle } from '../utils'; import { ContactAvatar, GroupAvatar } from './Avatar'; import { Button } from './Button'; import { ContactName } from './ContactNameV2'; import { Icon } from './Icon'; +import { useBoundHandler } from './ListItem/listItemUtils'; import Pressable from './Pressable'; import { ScreenHeader } from './ScreenHeader'; import { Text } from './TextV2'; @@ -40,6 +42,7 @@ interface Props { } export function UserProfileScreenView(props: Props) { + const theme = useTheme(); const insets = useSafeAreaInsets(); const currentUserId = useCurrentUserId(); const userContact = useContact(props.userId); @@ -78,7 +81,7 @@ export function UserProfileScreenView(props: Props) { }, [currentUserId, props.userId, userContact]); return ( - + } @@ -234,17 +237,20 @@ export function StatusDisplay({ ); } -export function PinnedGroupsDisplay( - props: { - groups: db.Group[]; - onPressGroup: (group: db.Group) => void; - } & ComponentProps -) { +export function PinnedGroupsDisplay({ + groups, + onPressGroup, + itemProps, +}: { + groups: db.Group[]; + onPressGroup: (group: db.Group) => void; + itemProps?: Omit, 'onPress'>; +}) { const windowDimensions = useWindowDimensions(); const [containerWidth, setContainerWidth] = useState(windowDimensions.width); const pinnedGroupsKey = useMemo(() => { - return props.groups.map((g) => g.id).join(','); - }, [props.groups]); + return groups.map((g) => g.id).join(','); + }, [groups]); useEffect(() => { if (pinnedGroupsKey.length) { @@ -257,9 +263,7 @@ export function PinnedGroupsDisplay( setContainerWidth(width); }; - const { groups, onPressGroup, ...rest } = props; - - if (!props.groups.length) { + if (!groups.length) { return null; } @@ -273,38 +277,59 @@ export function PinnedGroupsDisplay( > {groups.map((group, i) => { return ( - onPressGroup(group)} - {...rest} - > - - - - {group.title} - - - {i === 0 && ( - - {group.description} - - )} - - + showDescription={i === 0} + onPress={onPressGroup} + {...itemProps} + /> ); })} ); } +type GroupBlockProps = { + model: db.Group; + onPress: (group: db.Group) => void; + showDescription?: boolean; +} & Omit, 'onPress'>; + +function GroupBlock({ + model, + onPress, + showDescription, + ...rest +}: GroupBlockProps) { + const handlePress = useBoundHandler(model, onPress); + const title = useGroupTitle(model); + + return ( + + + + + {title} + + + {showDescription && ( + + {model.description} + + )} + + + ); +} + function UserInfoRow(props: { userId: string; hasNickname: boolean }) { const { didCopy, doCopy } = useCopy(props.userId); @@ -322,6 +347,7 @@ function UserInfoRow(props: { userId: string; hasNickname: boolean }) { <> - + + + {didCopy ? ( void; - onPressGroupMembers: (groupId: string) => void; - onPressManageChannels: (groupId: string) => void; - onPressInvite?: (group: db.Group) => void; - onPressGroupPrivacy: (groupId: string) => void; - onPressRoles: (groupId: string) => void; - onPressChannelMembers: (channelId: string) => void; - onPressChannelMeta: (channelId: string) => void; - onPressChannelTemplate: (channelId: string) => void; - onTogglePinned: () => void; - onPressLeave: () => Promise; - onSelectSort?: (sortBy: 'recency' | 'arranged') => void; + channel?: db.Channel | null; + markGroupRead: () => void; + markChannelRead: () => void; + onPressGroupMeta: () => void; + onPressGroupMembers: () => void; + onPressManageChannels: () => void; + onPressInvite?: () => void; + onPressGroupPrivacy: () => void; + onPressRoles: () => void; + onPressChannelMembers: () => void; + onPressChannelMeta: () => void; + onPressChannelTemplate: () => void; + togglePinned: () => void; + leaveGroup: () => Promise; + leaveChannel: () => void; + updateVolume: (level: ub.NotificationLevel | null) => void; + setChannelSortPreference?: (sortBy: 'recency' | 'arranged') => void; open: (chatId: string, chatType: 'group' | 'channel') => void; } | null; @@ -55,10 +64,15 @@ type ChatOptionsProviderProps = { onPressRoles: (groupId: string) => void; onSelectSort?: (sortBy: 'recency' | 'arranged') => void; onLeaveGroup?: () => void; + initialChat?: { + id: string; + type: 'group' | 'channel'; + }; }; export const ChatOptionsProvider = ({ children, + initialChat, useChannel = store.useChannel, useGroup = store.useGroup, onPressGroupMeta, @@ -76,7 +90,22 @@ export const ChatOptionsProvider = ({ const [chat, setChat] = useState<{ id: string; type: 'group' | 'channel'; - } | null>(null); + } | null>(initialChat ?? null); + + const openSheet = useCallback( + (chatId: string, chatType: 'group' | 'channel') => { + setChat({ + id: chatId, + type: chatType, + }); + setSheetOpen(true); + }, + [] + ); + + const closeSheet = useCallback(() => { + setSheetOpen(false); + }, []); const isChannel = chat?.type === 'channel'; const isGroup = chat?.type === 'group'; @@ -84,75 +113,207 @@ export const ChatOptionsProvider = ({ const { data: channel } = useChannel({ id: isChannel ? chat.id : undefined, }); + const channelTitle = useChannelTitle(channel ?? null); + const groupId = isGroup ? chat.id : channel?.groupId ?? undefined; const { data: group } = useGroup({ - id: isGroup ? chat.id : channel?.groupId ?? undefined, + id: groupId, }); - const groupChannels = useMemo(() => { - return group?.channels ?? []; - }, [group?.channels]); + useEffect(() => { + if (groupId) { + store.syncGroup(groupId, { priority: store.SyncPriority.Medium }); + } + }, [groupId]); - const onTogglePinned = useCallback(() => { + const togglePinned = useCallback(() => { if (group && group.channels?.[0]) { group.pin ? store.unpinItem(group.pin) : store.pinGroup(group); } }, [group]); - const onPressLeave = useCallback(async () => { + const updateVolume = useCallback( + (level: ub.NotificationLevel | null) => { + if (chat?.type === 'group' && group) { + store.setGroupVolumeLevel({ group, level }); + } else if (chat?.type === 'channel' && channel) { + store.setChannelVolumeLevel({ channel, level }); + } + }, + [channel, chat, group] + ); + + const leaveGroup = useCallback(async () => { if (group) { await store.leaveGroup(group.id); } navigateOnLeave?.(); - }, [group, navigateOnLeave]); + closeSheet(); + }, [closeSheet, group, navigateOnLeave]); - const onSelectSort = useCallback((sortBy: 'recency' | 'arranged') => { - db.channelSortPreference.setValue(sortBy); - }, []); + const onLeaveChannelConfirmed = useCallback(() => { + if (!channel) { + return; + } + if (channel.type === 'dm' || channel.type === 'groupDm') { + store.respondToDMInvite({ + channel, + accept: false, + }); + } else { + store.leaveGroupChannel(channel.id); + } + closeSheet(); + }, [channel, closeSheet]); - const open = useCallback((chatId: string, chatType: 'group' | 'channel') => { - setChat({ - id: chatId, - type: chatType, - }); - setSheetOpen(true); - }, []); + const leaveChannel = useCallback(() => { + if (isWeb) { + return onLeaveChannelConfirmed(); + } + Alert.alert( + `Leave ${channelTitle}?`, + 'You will no longer receive updates from this channel.', + [ + { + text: 'Cancel', + onPress: () => console.log('Cancel Pressed'), + style: 'cancel', + }, + { + text: 'Leave', + style: 'destructive', + onPress: onLeaveChannelConfirmed, + }, + ] + ); + }, [channelTitle, onLeaveChannelConfirmed]); + + const markGroupRead = useCallback(() => { + if (group) { + store.markGroupRead(group, true); + } + closeSheet(); + }, [closeSheet, group]); + + const markChannelRead = useCallback(() => { + if (channel && !channel.isPendingChannel) { + store.markChannelRead(channel); + } + }, [channel]); + + const setChannelSortPreference = useCallback( + (sortBy: 'recency' | 'arranged') => { + db.channelSortPreference.setValue(sortBy); + closeSheet(); + }, + [closeSheet] + ); + + const handlePressInvite = useCallback(() => { + if (group) { + onPressInvite?.(group); + closeSheet(); + } + }, [closeSheet, group, onPressInvite]); + + const handlePressChannelMeta = useCallback(() => { + if (channel) { + onPressChannelMeta?.(channel?.id); + closeSheet(); + } + }, [channel, closeSheet, onPressChannelMeta]); + + const handlePressGroupMeta = useCallback(() => { + if (group) { + onPressGroupMeta?.(group.id); + closeSheet(); + } + }, [closeSheet, group, onPressGroupMeta]); + + const handlePressManageChannels = useCallback(() => { + if (group) { + onPressManageChannels?.(group.id); + closeSheet(); + } + }, [group, onPressManageChannels, closeSheet]); + + const handlePressChannelMembers = useCallback(() => { + if (channel) { + onPressChannelMembers?.(channel.id); + closeSheet(); + } + }, [channel, closeSheet, onPressChannelMembers]); + + const handlePressGroupMembers = useCallback(() => { + if (group) { + onPressGroupMembers?.(group.id); + closeSheet(); + } + }, [closeSheet, group, onPressGroupMembers]); + + const handlePressGroupPrivacy = useCallback(() => { + if (group) { + onPressGroupPrivacy?.(group.id); + closeSheet(); + } + }, [closeSheet, group, onPressGroupPrivacy]); + + const handlePressGroupRoles = useCallback(() => { + if (group) { + onPressRoles?.(group.id); + } + }, [group, onPressRoles]); + + const handlePressChannelTemplate = useCallback(() => { + if (channel) { + onPressChannelTemplate(channel.id); + closeSheet(); + } + }, [channel, onPressChannelTemplate]); const contextValue: ChatOptionsContextValue = useMemo( () => ({ useGroup, group, - groupChannels, - onPressGroupMeta, - onPressGroupMembers, - onPressManageChannels, - onPressInvite, - onPressGroupPrivacy, - onPressRoles, - onPressLeave, - onTogglePinned, - onPressChannelMembers, - onPressChannelMeta, - onPressChannelTemplate, - onSelectSort, - open, + channel, + markGroupRead, + markChannelRead, + onPressGroupMeta: handlePressGroupMeta, + onPressChannelTemplate: handlePressChannelTemplate, + onPressGroupMembers: handlePressGroupMembers, + onPressManageChannels: handlePressManageChannels, + onPressInvite: handlePressInvite, + onPressGroupPrivacy: handlePressGroupPrivacy, + onPressRoles: handlePressGroupRoles, + leaveGroup, + leaveChannel, + togglePinned, + updateVolume, + open: openSheet, + onPressChannelMembers: handlePressChannelMembers, + onPressChannelMeta: handlePressChannelMeta, + setChannelSortPreference, }), [ + useGroup, group, - groupChannels, - onPressChannelMembers, - onPressChannelMeta, + channel, + markGroupRead, + markChannelRead, + handlePressGroupMeta, + handlePressGroupMembers, + handlePressManageChannels, + handlePressInvite, + handlePressGroupPrivacy, + handlePressGroupRoles, onPressChannelTemplate, - onPressGroupMembers, - onPressGroupMeta, - onPressGroupPrivacy, - onPressInvite, - onPressLeave, - onPressManageChannels, - onPressRoles, - onSelectSort, - onTogglePinned, - open, - useGroup, + leaveGroup, + leaveChannel, + togglePinned, + updateVolume, + openSheet, + handlePressChannelMembers, + handlePressChannelMeta, + setChannelSortPreference, ] ); diff --git a/packages/ui/src/contexts/componentsKits.tsx b/packages/ui/src/contexts/componentsKits.tsx index 265db7000c..34b977af9a 100644 --- a/packages/ui/src/contexts/componentsKits.tsx +++ b/packages/ui/src/contexts/componentsKits.tsx @@ -25,7 +25,7 @@ type RenderItemFunction = (props: { setEditingPost?: (post: db.Post | undefined) => void; setViewReactionsPost?: (post: db.Post) => void; editPost?: (post: db.Post, content: Story) => Promise; - onPressRetry: (post: db.Post) => void; + onPressRetry?: (post: db.Post) => Promise; onPressDelete: (post: db.Post) => void; isHighlighted?: boolean; }) => ReactElement | null; diff --git a/packages/ui/src/contexts/globalSearch.tsx b/packages/ui/src/contexts/globalSearch.tsx new file mode 100644 index 0000000000..5e609f6a9c --- /dev/null +++ b/packages/ui/src/contexts/globalSearch.tsx @@ -0,0 +1,25 @@ +import React, { createContext, useContext, useState } from 'react'; + +interface GlobalSearchContextType { + isOpen: boolean; + setIsOpen: (open: boolean) => void; +} + +const GlobalSearchContext = createContext({ + isOpen: false, + setIsOpen: () => {}, +}); + +export function GlobalSearchProvider({ children }: { children: React.ReactNode }) { + const [isOpen, setIsOpen] = useState(false); + + return ( + + {children} + + ); +} + +export function useGlobalSearch() { + return useContext(GlobalSearchContext); +} diff --git a/packages/ui/src/hooks/groupsSorters.ts b/packages/ui/src/hooks/groupsSorters.ts index 2c256119bb..3044bdeaac 100644 --- a/packages/ui/src/hooks/groupsSorters.ts +++ b/packages/ui/src/hooks/groupsSorters.ts @@ -3,6 +3,7 @@ import * as db from '@tloncorp/shared/db'; import anyAscii from 'any-ascii'; import { useMemo } from 'react'; +import { useCalm } from '../contexts'; import * as utils from '../utils'; export type AlphaGroupsSegment = { @@ -14,6 +15,8 @@ export type AlphaSegmentedGroups = AlphaGroupsSegment[]; const logger = createDevLogger('groupSorter', false); +type GroupSegment = { id: string; sortable: string; group: db.Group }; + export function useAlphabeticallySegmentedGroups({ groups, enabled, @@ -21,19 +24,17 @@ export function useAlphabeticallySegmentedGroups({ groups: db.Group[]; enabled?: boolean; }): AlphaSegmentedGroups { - const segmentedContacts = useMemo(() => { + const { disableNicknames } = useCalm(); + const segmentedGroups = useMemo(() => { if (!enabled) return []; return logSyncDuration('useAlphabeticallySegmentedContacts', logger, () => { - const segmented: Record< - string, - { id: string; sortable: string; group: db.Group }[] - > = {}; + const segmented: Record = {}; // convert contact to alphabetical representation and bucket by first letter for (const group of groups) { - const sortableName = group.title - ? anyAscii(group.title.replace(/[~-]/g, '')) - : group.id.replace(/[~-]/g, ''); + const sortableName = anyAscii( + utils.getGroupTitle(group, disableNicknames).replace(/[~-]/g, '') + ); const firstAlpha = utils.getFirstAlphabeticalChar(sortableName); if (!segmented[firstAlpha]) { segmented[firstAlpha] = []; @@ -50,60 +51,29 @@ export function useAlphabeticallySegmentedGroups({ delete segmented['Other']; // order groupings alphabetically and sort hits within each bucket - const segmentedContacts = Object.entries(segmented) + const segmentedGroups = Object.entries(segmented) .filter(([_k, results]) => results.length > 0) .sort(([a], [b]) => a.localeCompare(b)) .map(([label, results]) => { - const segmentGroups = results.map((r) => r.group).sort(groupsSorter); + const segmentGroups = results.sort(groupsSorter).map((r) => r.group); return { label, data: segmentGroups }; }); // add non-alphabetical names to the end if (nonAlphaNames && nonAlphaNames.length) { - segmentedContacts.push({ + segmentedGroups.push({ label: '_', data: nonAlphaNames.map((r) => r.group), }); } - return segmentedContacts; + return segmentedGroups; }); - }, [enabled, groups]); + }, [disableNicknames, enabled, groups]); - return segmentedContacts; + return segmentedGroups; } -function groupsSorter(a: db.Group, b: db.Group): number { - if (a.title && !b.title) { - return -1; - } - - if (b.title && !a.title) { - return 1; - } - - if (b.title && a.title) { - // prioritize nicknames that don't look "urbity" - const aIsBadNickname = a.title.charAt(0) === '~'; - const bIsBadNickname = b.title.charAt(0) === '~'; - if (aIsBadNickname && !bIsBadNickname) { - return 1; - } - if (!aIsBadNickname && bIsBadNickname) { - return -1; - } - - // otherwise, just alphabetical - const aTitleClean = a.title.replace(/[~-]/g, ''); - const bTitleClean = b.title.replace(/[~-]/g, ''); - return aTitleClean.localeCompare(bTitleClean); - } - - try { - const aTitleClean = a.id.split('/')[1].replace(/[~-]/g, ''); - const bTitleClean = b.id.split('/')[1].replace(/[~-]/g, ''); - return aTitleClean.localeCompare(bTitleClean); - } catch (e) { - return 0; - } +function groupsSorter(a: GroupSegment, b: GroupSegment): number { + return a.sortable.localeCompare(b.sortable); } diff --git a/packages/ui/src/index.tsx b/packages/ui/src/index.tsx index e9c734f0de..89e10ee880 100644 --- a/packages/ui/src/index.tsx +++ b/packages/ui/src/index.tsx @@ -1,7 +1,5 @@ -export * from './components/AddGroupSheet'; export * from './components/Activity/ActivityScreenView'; -export { ActionSheet } from './components/ActionSheet'; -export * from './components/AddChats'; +export * from './components/ActionSheet'; export * from './components/AppSetting'; export * from './components/Avatar'; export * from './components/BigInput'; @@ -30,7 +28,6 @@ export * from './components/ContactsScreenView'; export * from './components/AddContactsView'; export * from './components/ContactRow'; export * from './components/ContentReference'; -export * from './components/CreateGroupView'; export * from './components/PostContent'; export * from './components/DeleteSheet'; export * from './components/EditProfileScreenView'; @@ -38,10 +35,11 @@ export * from './components/EditableProfileImages'; export * from './components/Embed'; export * from './components/Emoji'; export * from './components/FeatureFlagScreenView'; -export * from './components/FindGroupsView'; export * from './components/FloatingActionButton'; export * from './components/FormInput'; export * from './components/Form'; +export * from './components/GlobalSearch'; +export * from './contexts/globalSearch'; export * from './components/GalleryPost'; export * from './components/GroupChannelsScreenView'; export * from './components/GroupMembersScreenView'; @@ -63,6 +61,7 @@ export * from './components/NavBarView'; export * from './components/Modal'; export * from './components/NavBar'; export * from './components/Onboarding'; +export * from './components/Overlay'; export * from './components/ParentAgnosticKeyboardAvoidingView'; export * from './components/PostScreenView'; export { default as Pressable } from './components/Pressable'; @@ -79,8 +78,10 @@ export * from './components/View'; export * from './components/WelcomeSheet'; export * from './components/Image'; export * from './components/ArvosDiscussing'; +export * from './components/PersonalInviteSheet'; export * as Form from './components/Form'; export * from './contexts'; +export { GlobalSearchProvider } from './contexts/globalSearch'; export * from './tamagui.config'; export * from './types'; export * from './utils'; diff --git a/packages/ui/src/utils/channelUtils.tsx b/packages/ui/src/utils/channelUtils.tsx index 99eb19d578..61b3e65831 100644 --- a/packages/ui/src/utils/channelUtils.tsx +++ b/packages/ui/src/utils/channelUtils.tsx @@ -5,15 +5,18 @@ import { useMemo } from 'react'; import type { IconType } from '../components/Icon'; import { useCalm } from '../contexts/appDataContext'; +import { formatUserId } from './user'; export function getChannelMemberName( member: db.ChatMember, disableNicknames: boolean ) { if (disableNicknames) { - return member.contactId; + return formatUserId(member.contactId); } - return member.contact?.nickname || member.contactId; + return member.contact?.nickname + ? member.contact.nickname + : formatUserId(member.contactId)?.display; } export function useChannelMemberName(member: db.ChatMember) { @@ -21,7 +24,7 @@ export function useChannelMemberName(member: db.ChatMember) { return getChannelMemberName(member, disableNicknames); } -function getChannelTitle({ +export function getChannelTitle({ usesMemberListAsFallbackTitle, channelTitle, members, @@ -45,25 +48,66 @@ function getChannelTitle({ } } +export function useChatTitle( + channel: db.Channel | null, + group?: db.Group | null +) { + const { disableNicknames } = useCalm(); + + if (!channel || (channel.groupId && !group)) { + return null; + } + + if (group?.channels?.length === 1) { + return getGroupTitle(group, disableNicknames); + } else { + return getChannelTitle({ + ...configurationFromChannel(channel), + channelTitle: channel.title, + members: channel.members, + disableNicknames, + }); + } +} + export function useChannelTitle(channel: db.Channel | null) { const { disableNicknames } = useCalm(); - const usesMemberListAsFallbackTitle = useMemo( - () => configurationFromChannel(channel)?.usesMemberListAsFallbackTitle, - [channel] - ); + return useMemo(() => { + if (channel === null) { + return null; + } + return getChannelTitle({ + ...configurationFromChannel(channel), + channelTitle: channel.title, + members: channel.members, + disableNicknames, + }); + }, [channel, disableNicknames]); +} - return useMemo( - () => - channel == null || usesMemberListAsFallbackTitle == null - ? null - : getChannelTitle({ - usesMemberListAsFallbackTitle, - channelTitle: channel.title, - members: channel.members, - disableNicknames, - }), - [channel, disableNicknames, usesMemberListAsFallbackTitle] - ); +export function getGroupTitle( + group: db.Group, + disableNicknames: boolean +): string { + if (group?.title && group?.title !== '') { + return group.title; + } else if ((group?.members?.length ?? 0) > 1) { + return ( + group.members + ?.map((member) => getChannelMemberName(member, disableNicknames)) + .join(', ') ?? 'No title' + ); + } else { + return 'Untitled group'; + } +} + +export function useGroupTitle(group?: db.Group | null) { + const { disableNicknames } = useCalm(); + if (!group) { + return null; + } + return getGroupTitle(group, disableNicknames); } export function isDmChannel(channel: db.Channel): boolean { diff --git a/patches/react-native-storage@1.0.1.patch b/patches/react-native-storage@1.0.1.patch deleted file mode 100644 index 56a8b2080d..0000000000 --- a/patches/react-native-storage@1.0.1.patch +++ /dev/null @@ -1,17 +0,0 @@ -diff --git a/CHANGELOG.md b/CHANGELOG.md -deleted file mode 100644 -index b44df6eb3b5841e3536c2a0af1ac893893c28a9b..0000000000000000000000000000000000000000 -diff --git a/package.json b/package.json -index b20cb155ffaa3682de3b7f4f09839d68d38d2f62..51ab1919f4d5c25c785b71f356a10d87a8341d7b 100644 ---- a/package.json -+++ b/package.json -@@ -30,8 +30,7 @@ - "clean": "rm -rf lib/", - "build": "yarn clean && rollup -c && cp src/storage.d.ts lib/", - "prepare": "yarn build", -- "test": "jest", -- "postinstall": "opencollective-postinstall" -+ "test": "jest" - }, - "jest": { - "verbose": true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 67792eb023..6049a6936e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,9 +48,6 @@ patchedDependencies: react-native-reanimated@3.8.1: hash: iololnqcieadxwydikuxdlowf4 path: patches/react-native-reanimated@3.8.1.patch - react-native-storage@1.0.1: - hash: pragjrandpl4ksuijrhddz3ljq - path: patches/react-native-storage@1.0.1.patch react-native@0.73.4: hash: ouiwprrx2mkoquswbpf4us43yu path: patches/react-native@0.73.4.patch @@ -321,9 +318,6 @@ importers: react-native-sse: specifier: ^1.2.1 version: 1.2.1 - react-native-storage: - specifier: ^1.0.1 - version: 1.0.1(patch_hash=pragjrandpl4ksuijrhddz3ljq) react-native-svg: specifier: ^15.0.0 version: 15.0.0(react-native@0.73.9(@babel/core@7.25.2)(@babel/preset-env@7.23.7(@babel/core@7.25.2))(encoding@0.1.13)(react@18.2.0))(react@18.2.0) @@ -1160,6 +1154,9 @@ importers: react-dom: specifier: ^18.2.0 version: 18.2.0(react@18.2.0) + react-dropzone: + specifier: ^14.3.5 + version: 14.3.5(react@18.2.0) react-error-boundary: specifier: ^3.1.4 version: 3.1.4(react@18.2.0) @@ -1617,6 +1614,9 @@ importers: react-native-svg: specifier: ^15.0.0 version: 15.0.0(react-native@0.73.9(@babel/core@7.25.2)(@babel/preset-env@7.26.0(@babel/core@7.25.2))(encoding@0.1.13)(react@18.2.0))(react@18.2.0) + react-qr-code: + specifier: ^2.0.12 + version: 2.0.12(react-native-svg@15.0.0(react-native@0.73.9(@babel/core@7.25.2)(@babel/preset-env@7.26.0(@babel/core@7.25.2))(encoding@0.1.13)(react@18.2.0))(react@18.2.0))(react@18.2.0) react-tweet: specifier: ^3.0.4 version: 3.1.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -7069,10 +7069,6 @@ packages: anser@1.4.10: resolution: {integrity: sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww==} - ansi-escapes@1.4.0: - resolution: {integrity: sha512-wiXutNjDUlNEDWHcYH3jtZUhd3c4/VojassD8zHdHCY13xbZy2XbW+NKQwA0tWGBVzDA9qEzYwfoSsWmviidhw==} - engines: {node: '>=0.10.0'} - ansi-escapes@4.3.2: resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} engines: {node: '>=8'} @@ -7084,14 +7080,6 @@ packages: ansi-fragments@0.2.1: resolution: {integrity: sha512-DykbNHxuXQwUDRv5ibc2b0x7uw7wmwOGLBUd5RmaQ5z8Lhx19vwvKV+FAsM5rEA6dEcHxX+/Ad5s9eF2k2bB+w==} - ansi-regex@2.1.1: - resolution: {integrity: sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==} - engines: {node: '>=0.10.0'} - - ansi-regex@3.0.1: - resolution: {integrity: sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw==} - engines: {node: '>=4'} - ansi-regex@4.1.1: resolution: {integrity: sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==} engines: {node: '>=6'} @@ -7104,10 +7092,6 @@ packages: resolution: {integrity: sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==} engines: {node: '>=12'} - ansi-styles@2.2.1: - resolution: {integrity: sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA==} - engines: {node: '>=0.10.0'} - ansi-styles@3.2.1: resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} engines: {node: '>=4'} @@ -7279,6 +7263,10 @@ packages: engines: {node: '>= 4.5.0'} hasBin: true + attr-accept@2.2.5: + resolution: {integrity: sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==} + engines: {node: '>=4'} + autoprefixer@10.4.16: resolution: {integrity: sha512-7vd3UC6xKp0HLfua5IjZlcXvGAGy7cBAXTg2lyQ/8WpNhd6SiZ8Be+xm3FyBSYJx5GKcpRCzBh7RH4/0dnY+uQ==} engines: {node: ^10 || ^12 || >=14} @@ -7377,9 +7365,6 @@ packages: babel-plugin-transform-flow-enums@0.0.2: resolution: {integrity: sha512-g4aaCrDDOsWjbm0PUUeVnkcVd6AKJsVc/MbnPhEotEpkeJQP6b8nzewohQi7+QS8UyPehOhGWn0nOwjvWpmMvQ==} - babel-polyfill@6.23.0: - resolution: {integrity: sha512-0l7mVU+LrQ2X/ZTUq63T5i3VyR2aTgcRTFmBcD6djQ/Fek6q1A9t5u0F4jZVYHzp78jwWAzGfLpAY1b4/I3lfg==} - babel-preset-current-node-syntax@1.0.1: resolution: {integrity: sha512-M7LQ0bxarkxQoN+vz5aJPsLBn77n8QgTFmo8WK0/44auK2xlCXrYcUxHFxgU7qW5Yzw/CjmLRK2uJzaCd7LvqQ==} peerDependencies: @@ -7399,9 +7384,6 @@ packages: peerDependencies: '@babel/core': ^7.0.0 - babel-runtime@6.26.0: - resolution: {integrity: sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g==} - badgin@1.2.3: resolution: {integrity: sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==} @@ -7612,10 +7594,6 @@ packages: resolution: {integrity: sha512-13sOfMv2+DWduEU+/xbun3LScLoqN17nBeTLUsmDfKdoiC1fr0n9PU4guu4AhRcOVFk/sW8LyZWHuhWtQZiF+g==} engines: {node: '>=4'} - chalk@1.1.3: - resolution: {integrity: sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==} - engines: {node: '>=0.10.0'} - chalk@2.4.2: resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} engines: {node: '>=4'} @@ -7653,9 +7631,6 @@ packages: character-reference-invalid@2.0.1: resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} - chardet@0.4.2: - resolution: {integrity: sha512-j/Toj7f1z98Hh2cYo2BVr85EpIRWqUi7rtRSGxh/cqUjqrnJe9l9UE7IUGd2vQ2p+kSHLkSzObQPZPLUC6TQwg==} - chardet@0.7.0: resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} @@ -7737,9 +7712,6 @@ packages: resolution: {integrity: sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==} engines: {node: '>=18'} - cli-width@2.2.1: - resolution: {integrity: sha512-GRMWDxpOB6Dgk2E5Uo+3eEBvtOOlimMmpbFiKuLFnQzYDavtLFY3K5ona41jgN/WdRZtG7utuVSVTL4HbZHGkw==} - cli-width@3.0.0: resolution: {integrity: sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==} engines: {node: '>= 10'} @@ -7925,10 +7897,6 @@ packages: core-js-compat@3.39.0: resolution: {integrity: sha512-VgEUx3VwlExr5no0tXlBt+silBvhTryPwCXRI2Id1PN8WTKu7MreethvddqOubrYxkFdv/RnYrqlv1sFNAUelw==} - core-js@2.6.12: - resolution: {integrity: sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==} - deprecated: core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js. - core-js@3.24.1: resolution: {integrity: sha512-0QTBSYSUZ6Gq21utGzkfITDylE8jWC9Ne1D2MrhvlsZBI1x39OdDIVbzSqtgMndIy6BlHxBXpMGqzZmnztg2rg==} @@ -8939,10 +8907,6 @@ packages: resolution: {integrity: sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==} engines: {node: '>= 0.10.0'} - external-editor@2.2.0: - resolution: {integrity: sha512-bSn6gvGxKt+b7+6TKEv1ZycHleA7aHhRHyAqJyp5pbUFuYYNIzpZnQDk7AsYckyWdEnTeAnay0aCy2aV6iTk9A==} - engines: {node: '>=0.12'} - external-editor@3.1.0: resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==} engines: {node: '>=4'} @@ -9012,10 +8976,6 @@ packages: fflate@0.4.8: resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==} - figures@2.0.0: - resolution: {integrity: sha512-Oa2M9atig69ZkfwiApY8F2Yy+tzMbazyvqv21R0NsSC8floSOC09BbT1ITWAdoMGQvJ/aZnR1KMwdx9tvHnTNA==} - engines: {node: '>=4'} - figures@3.2.0: resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} engines: {node: '>=8'} @@ -9024,6 +8984,10 @@ packages: resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} engines: {node: ^10.12.0 || >=12.0.0} + file-selector@2.1.2: + resolution: {integrity: sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==} + engines: {node: '>= 12'} + file-uri-to-path@1.0.0: resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} @@ -9382,10 +9346,6 @@ packages: resolution: {integrity: sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==} engines: {node: '>=6'} - has-ansi@2.0.0: - resolution: {integrity: sha512-C8vBJ8DwUCx19vhm7urhTuUsr4/IyP6l4VzNQDv+ryHQObW3TTTp9yB68WpYgRe2bbaGuZ/se74IqFeVnMnLZg==} - engines: {node: '>=0.10.0'} - has-bigints@1.0.2: resolution: {integrity: sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==} @@ -9619,9 +9579,6 @@ packages: inline-style-prefixer@6.0.4: resolution: {integrity: sha512-FwXmZC2zbeeS7NzGjJ6pAiqRhXR0ugUShSNb6GApMl6da0/XGc4MOJsoWAywia52EEWbXNSy0pzkwz/+Y+swSg==} - inquirer@3.0.6: - resolution: {integrity: sha512-thluxTGBXUGb8DuQcvH9/CM/CrcGyB5xUpWc9x6Slqcq1z/hRr2a6KxUpX4ddRfmbe0hg3E4jTvo5833aWz3BA==} - inquirer@8.2.4: resolution: {integrity: sha512-nn4F01dxU8VeKfq192IjLsxu0/OmMZ4Lg3xKAns148rCaXP6ntAoEkVYZThWjwON8AlzdZZi6oqnhNbxUG9hVg==} engines: {node: '>=12.0.0'} @@ -10798,9 +10755,6 @@ packages: resolution: {integrity: sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==} engines: {node: '>= 6'} - minimist@1.2.0: - resolution: {integrity: sha512-7Wl+Jz+IGWuSdgsQEJ4JunV0si/iMhg42MnQQG6h1R6TNeVenp4U9x5CC5v/gYqz/fENLQITAWXidNtVL0NNbw==} - minimist@1.2.6: resolution: {integrity: sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==} @@ -10885,9 +10839,6 @@ packages: resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==} hasBin: true - mute-stream@0.0.7: - resolution: {integrity: sha512-r65nCZhrbXXb6dXOACihYApHw2Q6pV0M3V0PSxd74N0+D8nzAdEAITq2oAjA1jVnKI+tGvEBUpqiMh0+rW6zDQ==} - mute-stream@0.0.8: resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==} @@ -10953,9 +10904,6 @@ packages: resolution: {integrity: sha512-tmPX422rYgofd4epzrNoOXiE8XFZYOcCq1vD7MAXCDO+O+zndlA2ztdKKMa+EeuBG5tHETpr4ml4RGgpqDCCAg==} engines: {node: '>= 0.10.5'} - node-fetch@1.6.3: - resolution: {integrity: sha512-BDxbhLHXFFFvilHjh9xihcDyPkXQ+kjblxnl82zAX41xUYSNvuRpFRznmldR9+OKu+p+ULZ7hNoyunlLB5ecUA==} - node-fetch@2.6.12: resolution: {integrity: sha512-C/fGU2E8ToujUivIO0H+tpQ6HWo4eEmchoPIoXtxCrVghxdKq+QOHqEZW7tuP3KlV3bC8FRMO5nMCC7Zm1VP6g==} engines: {node: 4.x || >=6.0.0} @@ -11140,14 +11088,6 @@ packages: resolution: {integrity: sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==} hasBin: true - opencollective@1.0.3: - resolution: {integrity: sha512-YBRI0Qa8+Ui0/STV1qYuPrJm889PT3oCPHMVoL+8Y3nwCffj7PSrB2NlGgrhgBKDujxTjxknHWJ/FiqOsYcIDw==} - hasBin: true - - opn@4.0.2: - resolution: {integrity: sha512-iPBWbPP4OEOzR1xfhpGLDh+ypKBOygunZhM9jBtA7FS5sKjEiMZw0EFb82hnDOmTZX90ZWLoZKUza4cVt8MexA==} - engines: {node: '>=0.10.0'} - optionator@0.9.3: resolution: {integrity: sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==} engines: {node: '>= 0.8.0'} @@ -11361,14 +11301,6 @@ packages: resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} engines: {node: '>=6'} - pinkie-promise@2.0.1: - resolution: {integrity: sha512-0Gni6D4UcLTbv9c57DfxDGdr41XfgUjqWZu492f0cIGr16zDU06BWP/RAEvOuo7CQ0CNjHaLlM59YJJFm3NWlw==} - engines: {node: '>=0.10.0'} - - pinkie@2.0.4: - resolution: {integrity: sha512-MnUuEycAemtSaeFSjXKW/aroV7akBbY+Sv+RkyqFjgAe73F+MR0TBWKBRDkmfWq/HiFmdavfZ1G7h4SPZXaCSg==} - engines: {node: '>=0.10.0'} - pirates@3.0.2: resolution: {integrity: sha512-c5CgUJq6H2k6MJz72Ak1F5sN9n9wlSlJyEnwvpm9/y3WB4E3pHBDT2c6PEiS1vyJvq2bUxUAIu0EGf8Cx4Ic7Q==} engines: {node: '>= 4'} @@ -11850,6 +11782,12 @@ packages: peerDependencies: react: ^18.2.0 + react-dropzone@14.3.5: + resolution: {integrity: sha512-9nDUaEEpqZLOz5v5SUcFA0CjM4vq8YbqO0WRls+EYT7+DvxUdzDPKNCPLqGfj3YL9MsniCLCD4RFA6M95V6KMQ==} + engines: {node: '>= 10.13'} + peerDependencies: + react: '>= 16.8 || 18.0.0' + react-error-boundary@3.1.4: resolution: {integrity: sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==} engines: {node: '>=10', npm: '>=6'} @@ -11998,9 +11936,6 @@ packages: react-native-sse@1.2.1: resolution: {integrity: sha512-zejanlScF+IB9tYnbdry0MT34qjBXbiV/E72qGz33W/tX1bx8MXsbB4lxiuPETc9v/008vYZ60yjIstW22VlVg==} - react-native-storage@1.0.1: - resolution: {integrity: sha512-fXT2+zhkfHj3E1/ekbymO8JwcDGgnxeWBiNIa7Al14qB4i3MSOF88nyjIRuTyBsEwZl/f6JG7l+zwd/20+bmlA==} - react-native-svg-transformer@1.3.0: resolution: {integrity: sha512-SV92uRjENDuanHLVuLy2Sdvt6f8vu7qnG8vC9CwBiAXV0BpWN4/wPvfc+r2WPAkcctRZLLOvrGnGA2o8nZd0cg==} peerDependencies: @@ -12239,12 +12174,6 @@ packages: regenerate@1.4.2: resolution: {integrity: sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==} - regenerator-runtime@0.10.5: - resolution: {integrity: sha512-02YopEIhAgiBHWeoTiA8aitHDt8z6w+rQqNuIftlM+ZtvSl/brTouaU7DW6GO/cHtvxJvS4Hwv2ibKdxIRi24w==} - - regenerator-runtime@0.11.1: - resolution: {integrity: sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==} - regenerator-runtime@0.13.11: resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} @@ -12443,9 +12372,6 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - rx@4.1.0: - resolution: {integrity: sha512-CiaiuN6gapkdl+cZUr67W6I8jquN4lkak3vtIsIWCl4XIPP8ffsoyN6/+PuGXnQy8Cu8W2y9Xxh31Rq4M6wUug==} - rxjs@7.8.1: resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==} @@ -12836,10 +12762,6 @@ packages: resolution: {integrity: sha512-9Ep08KAMUn0OadnVaBuRdE2l615CQ508kr0XMadjClfYpdCyvrbFp6Taebo8yyxokQ4viUd/xPPUA4FGgUa0ow==} engines: {node: '>=12.20'} - string-width@2.1.1: - resolution: {integrity: sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==} - engines: {node: '>=4'} - string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -12887,14 +12809,6 @@ packages: resolution: {integrity: sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==} engines: {node: '>=4'} - strip-ansi@3.0.1: - resolution: {integrity: sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==} - engines: {node: '>=0.10.0'} - - strip-ansi@4.0.0: - resolution: {integrity: sha512-4XaJ2zQdCzROZDivEVIDPkcQn8LMFSa8kj8Gxb/Lnwzv9A8VctNZ+lfivC/sV3ivW8ElJTERXZoPBRrZKkNKow==} - engines: {node: '>=4'} - strip-ansi@5.2.0: resolution: {integrity: sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==} engines: {node: '>=6'} @@ -12993,10 +12907,6 @@ packages: superstruct@0.6.2: resolution: {integrity: sha512-lvA97MFAJng3rfjcafT/zGTSWm6Tbpk++DP6It4Qg7oNaeM+2tdJMuVgGje21/bIpBEs6iQql1PJH6dKTjl4Ig==} - supports-color@2.0.0: - resolution: {integrity: sha512-KKNVtd6pCYgPIKU4cp2733HWYCpplQhddZLBUryaAHou723x+FRzQ5Df824Fj+IyyuiQTRoub4SnIFfIcrp70g==} - engines: {node: '>=0.8.0'} - supports-color@5.5.0: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} engines: {node: '>=4'} @@ -14476,7 +14386,7 @@ snapshots: '@aws-sdk/util-user-agent-node': 3.190.0 '@aws-sdk/util-utf8-browser': 3.188.0 '@aws-sdk/util-utf8-node': 3.188.0 - tslib: 2.6.2 + tslib: 2.8.1 transitivePeerDependencies: - aws-crt @@ -14532,7 +14442,7 @@ snapshots: dependencies: '@aws-sdk/property-provider': 3.190.0 '@aws-sdk/types': 3.190.0 - tslib: 2.6.2 + tslib: 2.8.1 '@aws-sdk/credential-provider-imds@3.190.0': dependencies: @@ -14551,7 +14461,7 @@ snapshots: '@aws-sdk/property-provider': 3.190.0 '@aws-sdk/shared-ini-file-loader': 3.190.0 '@aws-sdk/types': 3.190.0 - tslib: 2.6.2 + tslib: 2.8.1 transitivePeerDependencies: - aws-crt @@ -14575,7 +14485,7 @@ snapshots: '@aws-sdk/property-provider': 3.190.0 '@aws-sdk/shared-ini-file-loader': 3.190.0 '@aws-sdk/types': 3.190.0 - tslib: 2.6.2 + tslib: 2.8.1 '@aws-sdk/credential-provider-sso@3.190.0': dependencies: @@ -14583,7 +14493,7 @@ snapshots: '@aws-sdk/property-provider': 3.190.0 '@aws-sdk/shared-ini-file-loader': 3.190.0 '@aws-sdk/types': 3.190.0 - tslib: 2.6.2 + tslib: 2.8.1 transitivePeerDependencies: - aws-crt @@ -14591,14 +14501,14 @@ snapshots: dependencies: '@aws-sdk/property-provider': 3.190.0 '@aws-sdk/types': 3.190.0 - tslib: 2.6.2 + tslib: 2.8.1 '@aws-sdk/eventstream-codec@3.190.0': dependencies: '@aws-crypto/crc32': 2.0.0 '@aws-sdk/types': 3.190.0 '@aws-sdk/util-hex-encoding': 3.188.0 - tslib: 2.6.2 + tslib: 2.8.1 '@aws-sdk/eventstream-serde-browser@3.190.0': dependencies: @@ -14959,7 +14869,7 @@ snapshots: '@aws-sdk/util-uri-escape@3.188.0': dependencies: - tslib: 2.6.2 + tslib: 2.8.1 '@aws-sdk/util-user-agent-browser@3.190.0': dependencies: @@ -18267,7 +18177,7 @@ snapshots: rimraf: 2.7.1 sudo-prompt: 8.2.5 tmp: 0.0.33 - tslib: 2.6.2 + tslib: 2.8.1 transitivePeerDependencies: - supports-color @@ -18808,7 +18718,7 @@ snapshots: '@motionone/easing': 10.14.0 '@motionone/types': 10.14.0 '@motionone/utils': 10.14.0 - tslib: 2.6.2 + tslib: 2.8.1 '@motionone/dom@10.12.0': dependencies: @@ -18822,13 +18732,13 @@ snapshots: '@motionone/easing@10.14.0': dependencies: '@motionone/utils': 10.14.0 - tslib: 2.6.2 + tslib: 2.8.1 '@motionone/generators@10.14.0': dependencies: '@motionone/types': 10.14.0 '@motionone/utils': 10.14.0 - tslib: 2.6.2 + tslib: 2.8.1 '@motionone/types@10.14.0': {} @@ -18836,7 +18746,7 @@ snapshots: dependencies: '@motionone/types': 10.14.0 hey-listen: 1.0.8 - tslib: 2.6.2 + tslib: 2.8.1 '@mswjs/cookies@0.2.2': dependencies: @@ -20821,7 +20731,7 @@ snapshots: '@swc/helpers@0.5.13': dependencies: - tslib: 2.6.2 + tslib: 2.8.1 '@swc/types@0.1.12': dependencies: @@ -23034,8 +22944,6 @@ snapshots: anser@1.4.10: {} - ansi-escapes@1.4.0: {} - ansi-escapes@4.3.2: dependencies: type-fest: 0.21.3 @@ -23050,18 +22958,12 @@ snapshots: slice-ansi: 2.1.0 strip-ansi: 5.2.0 - ansi-regex@2.1.1: {} - - ansi-regex@3.0.1: {} - ansi-regex@4.1.1: {} ansi-regex@5.0.1: {} ansi-regex@6.0.1: {} - ansi-styles@2.2.1: {} - ansi-styles@3.2.1: dependencies: color-convert: 1.9.3 @@ -23235,7 +23137,7 @@ snapshots: ast-types@0.15.2: dependencies: - tslib: 2.6.2 + tslib: 2.8.1 astral-regex@1.0.0: {} @@ -23260,6 +23162,8 @@ snapshots: atob@2.1.2: {} + attr-accept@2.2.5: {} + autoprefixer@10.4.16(postcss@8.4.35): dependencies: browserslist: 4.22.2 @@ -23455,12 +23359,6 @@ snapshots: transitivePeerDependencies: - '@babel/core' - babel-polyfill@6.23.0: - dependencies: - babel-runtime: 6.26.0 - core-js: 2.6.12 - regenerator-runtime: 0.10.5 - babel-preset-current-node-syntax@1.0.1(@babel/core@7.25.2): dependencies: '@babel/core': 7.25.2 @@ -23531,11 +23429,6 @@ snapshots: babel-plugin-jest-hoist: 29.6.3 babel-preset-current-node-syntax: 1.0.1(@babel/core@7.25.2) - babel-runtime@6.26.0: - dependencies: - core-js: 2.6.12 - regenerator-runtime: 0.11.1 - badgin@1.2.3: {} balanced-match@1.0.2: {} @@ -23774,14 +23667,6 @@ snapshots: pathval: 1.1.1 type-detect: 4.0.8 - chalk@1.1.3: - dependencies: - ansi-styles: 2.2.1 - escape-string-regexp: 1.0.5 - has-ansi: 2.0.0 - strip-ansi: 3.0.1 - supports-color: 2.0.0 - chalk@2.4.2: dependencies: ansi-styles: 3.2.1 @@ -23815,8 +23700,6 @@ snapshots: character-reference-invalid@2.0.1: {} - chardet@0.4.2: {} - chardet@0.7.0: {} charenc@0.0.2: {} @@ -23918,8 +23801,6 @@ snapshots: slice-ansi: 5.0.0 string-width: 7.0.0 - cli-width@2.2.1: {} - cli-width@3.0.0: {} clipboard-copy@4.0.1: {} @@ -24121,8 +24002,6 @@ snapshots: dependencies: browserslist: 4.24.2 - core-js@2.6.12: {} - core-js@3.24.1: {} core-util-is@1.0.3: {} @@ -24525,7 +24404,7 @@ snapshots: dot-case@3.0.4: dependencies: no-case: 3.0.4 - tslib: 2.6.2 + tslib: 2.8.1 dotenv-expand@10.0.0: {} @@ -24598,6 +24477,7 @@ snapshots: encoding@0.1.13: dependencies: iconv-lite: 0.6.3 + optional: true end-of-stream@1.4.4: dependencies: @@ -25542,12 +25422,6 @@ snapshots: transitivePeerDependencies: - supports-color - external-editor@2.2.0: - dependencies: - chardet: 0.4.2 - iconv-lite: 0.4.24 - tmp: 0.0.33 - external-editor@3.1.0: dependencies: chardet: 0.7.0 @@ -25626,10 +25500,6 @@ snapshots: fflate@0.4.8: {} - figures@2.0.0: - dependencies: - escape-string-regexp: 1.0.5 - figures@3.2.0: dependencies: escape-string-regexp: 1.0.5 @@ -25638,6 +25508,10 @@ snapshots: dependencies: flat-cache: 3.0.4 + file-selector@2.1.2: + dependencies: + tslib: 2.8.1 + file-uri-to-path@1.0.0: {} filelist@1.0.4: @@ -26013,7 +25887,7 @@ snapshots: graphql-tag@2.12.6(graphql@15.8.0): dependencies: graphql: 15.8.0 - tslib: 2.6.2 + tslib: 2.8.1 graphql@15.8.0: {} @@ -26021,10 +25895,6 @@ snapshots: hard-rejection@2.1.0: {} - has-ansi@2.0.0: - dependencies: - ansi-regex: 2.1.1 - has-bigints@1.0.2: {} has-flag@3.0.0: {} @@ -26270,22 +26140,6 @@ snapshots: css-in-js-utils: 3.1.0 fast-loops: 1.1.3 - inquirer@3.0.6: - dependencies: - ansi-escapes: 1.4.0 - chalk: 1.1.3 - cli-cursor: 2.1.0 - cli-width: 2.2.1 - external-editor: 2.2.0 - figures: 2.0.0 - lodash: 4.17.21 - mute-stream: 0.0.7 - run-async: 2.4.1 - rx: 4.1.0 - string-width: 2.1.1 - strip-ansi: 3.0.1 - through: 2.3.8 - inquirer@8.2.4: dependencies: ansi-escapes: 4.3.2 @@ -27426,7 +27280,7 @@ snapshots: lower-case@2.0.2: dependencies: - tslib: 2.6.2 + tslib: 2.8.1 lru-cache@10.4.3: {} @@ -27982,8 +27836,6 @@ snapshots: is-plain-obj: 1.1.0 kind-of: 6.0.3 - minimist@1.2.0: {} - minimist@1.2.6: {} minipass-collect@1.0.2: @@ -28079,8 +27931,6 @@ snapshots: mustache@4.2.0: {} - mute-stream@0.0.7: {} - mute-stream@0.0.8: {} mv@2.1.1: @@ -28119,7 +27969,7 @@ snapshots: no-case@3.0.4: dependencies: lower-case: 2.0.2 - tslib: 2.6.2 + tslib: 2.8.1 nocache@3.0.4: {} @@ -28133,11 +27983,6 @@ snapshots: dependencies: minimatch: 3.1.2 - node-fetch@1.6.3: - dependencies: - encoding: 0.1.13 - is-stream: 1.1.0 - node-fetch@2.6.12(encoding@0.1.13): dependencies: whatwg-url: 5.0.0 @@ -28314,20 +28159,6 @@ snapshots: opencollective-postinstall@2.0.3: {} - opencollective@1.0.3: - dependencies: - babel-polyfill: 6.23.0 - chalk: 1.1.3 - inquirer: 3.0.6 - minimist: 1.2.0 - node-fetch: 1.6.3 - opn: 4.0.2 - - opn@4.0.2: - dependencies: - object-assign: 4.1.1 - pinkie-promise: 2.0.1 - optionator@0.9.3: dependencies: '@aashutoshrathi/word-wrap': 1.2.6 @@ -28525,12 +28356,6 @@ snapshots: pify@4.0.1: {} - pinkie-promise@2.0.1: - dependencies: - pinkie: 2.0.4 - - pinkie@2.0.4: {} - pirates@3.0.2: dependencies: node-modules-regexp: 1.0.0 @@ -29060,6 +28885,13 @@ snapshots: react: 18.2.0 scheduler: 0.23.0 + react-dropzone@14.3.5(react@18.2.0): + dependencies: + attr-accept: 2.2.5 + file-selector: 2.1.2 + prop-types: 15.8.1 + react: 18.2.0 + react-error-boundary@3.1.4(react@18.2.0): dependencies: '@babel/runtime': 7.25.9 @@ -29362,11 +29194,6 @@ snapshots: react-native-sse@1.2.1: {} - react-native-storage@1.0.1(patch_hash=pragjrandpl4ksuijrhddz3ljq): - dependencies: - opencollective: 1.0.3 - opencollective-postinstall: 2.0.3 - react-native-svg-transformer@1.3.0(react-native-svg@15.0.0(react-native@0.73.9(@babel/core@7.25.2)(@babel/preset-env@7.23.7(@babel/core@7.25.2))(encoding@0.1.13)(react@18.2.0))(react@18.2.0))(react-native@0.73.9(@babel/core@7.25.2)(@babel/preset-env@7.23.7(@babel/core@7.25.2))(encoding@0.1.13)(react@18.2.0))(typescript@5.4.5): dependencies: '@svgr/core': 8.1.0(typescript@5.4.5) @@ -29748,7 +29575,7 @@ snapshots: dependencies: react: 18.2.0 react-style-singleton: 2.2.1(@types/react@18.2.55)(react@18.2.0) - tslib: 2.6.2 + tslib: 2.8.1 optionalDependencies: '@types/react': 18.2.55 @@ -29806,7 +29633,7 @@ snapshots: get-nonce: 1.0.1 invariant: 2.2.4 react: 18.2.0 - tslib: 2.6.2 + tslib: 2.8.1 optionalDependencies: '@types/react': 18.2.55 @@ -29888,7 +29715,7 @@ snapshots: ast-types: 0.15.2 esprima: 4.0.1 source-map: 0.6.1 - tslib: 2.6.2 + tslib: 2.8.1 rechoir@0.6.2: dependencies: @@ -29937,10 +29764,6 @@ snapshots: regenerate@1.4.2: {} - regenerator-runtime@0.10.5: {} - - regenerator-runtime@0.11.1: {} - regenerator-runtime@0.13.11: {} regenerator-runtime@0.14.1: {} @@ -30155,8 +29978,6 @@ snapshots: dependencies: queue-microtask: 1.2.3 - rx@4.1.0: {} - rxjs@7.8.1: dependencies: tslib: 2.6.2 @@ -30578,11 +30399,6 @@ snapshots: char-regex: 2.0.1 strip-ansi: 7.1.0 - string-width@2.1.1: - dependencies: - is-fullwidth-code-point: 2.0.0 - strip-ansi: 4.0.0 - string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -30675,14 +30491,6 @@ snapshots: is-obj: 1.0.1 is-regexp: 1.0.0 - strip-ansi@3.0.1: - dependencies: - ansi-regex: 2.1.1 - - strip-ansi@4.0.0: - dependencies: - ansi-regex: 3.0.1 - strip-ansi@5.2.0: dependencies: ansi-regex: 4.1.1 @@ -30775,8 +30583,6 @@ snapshots: clone-deep: 2.0.2 kind-of: 6.0.3 - supports-color@2.0.0: {} - supports-color@5.5.0: dependencies: has-flag: 3.0.0 @@ -31367,7 +31173,7 @@ snapshots: use-callback-ref@1.3.0(@types/react@18.2.55)(react@18.2.0): dependencies: react: 18.2.0 - tslib: 2.6.2 + tslib: 2.8.1 optionalDependencies: '@types/react': 18.2.55 @@ -31393,7 +31199,7 @@ snapshots: dependencies: detect-node-es: 1.1.0 react: 18.2.0 - tslib: 2.6.2 + tslib: 2.8.1 optionalDependencies: '@types/react': 18.2.55 diff --git a/tm-alpha-desk/desk.docket-0 b/tm-alpha-desk/desk.docket-0 index 55a7b4cc92..82773e7602 100644 --- a/tm-alpha-desk/desk.docket-0 +++ b/tm-alpha-desk/desk.docket-0 @@ -2,7 +2,7 @@ info+'Start, host, and cultivate communities. Own your communications, organize your resources, and share documents. Tlon is a decentralized platform that offers a full, communal suite of tools for messaging, writing and sharing media with others.' color+0xde.dede image+'https://bootstrap.urbit.org/tlon.svg?v=1' - glob-http+['https://bootstrap.urbit.org/glob-0v5.9cu6g.jcpog.nc64g.8g7e2.7fj71.glob' 0v5.9cu6g.jcpog.nc64g.8g7e2.7fj71] + glob-http+['https://bootstrap.urbit.org/glob-0v3.go3d9.odevg.1ehie.a55ra.ng0jf.glob' 0v3.go3d9.odevg.1ehie.a55ra.ng0jf] base+'tm-alpha' version+[1 0 0] website+'https://tlon.io'