diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cdfa45c --- /dev/null +++ b/.gitignore @@ -0,0 +1,51 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/config/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc + + +Swift.gitignore + +build/ +DerivedData/ +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata/ + +*.moved-aside +*.xccheckout +*.xcscmblueprint + +*.hmap +*.ipa +*.dSYM.zip +*.dSYM + +# Swift Package Manager +# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. +# Packages/ +# Package.pins +# Package.resolved +# .build/ +# Add this line if you want to avoid checking in Xcode SPM integration. +.swiftpm/xcode + + +*.xcodeproj/* +!*.xcodeproj/project.pbxproj +!*.xcodeproj/xcshareddata/ +!*.xcworkspace/contents.xcworkspacedata +/*.gcno + +**/xcshareddata/WorkspaceSettings.xcsettings \ No newline at end of file diff --git a/Documentation/Appearance/Appearance.md b/Documentation/Appearance/Appearance.md new file mode 100644 index 0000000..3dfa769 --- /dev/null +++ b/Documentation/Appearance/Appearance.md @@ -0,0 +1,27 @@ +# Appearance + +The `Appearance` struct represents a set of predefined appearances used in the app's user interface such as colors and typography. +Use these colors to maintain consistency and familiarity in the user interface. + +## Example Usage +```swift +@Environment(\.appearance) var appearance + +var body: some View { + Text("New Chat") + .font(appearance.title) + .foregroundColor(appearance.primary) +} +``` + +## Customization +```swift +let appearance = Appearance(tint: .orange) + +var body: some View { + ChatView() + .environment(\.appearance, appearance) +} +``` + + diff --git a/Documentation/Appearance/Colors.md b/Documentation/Appearance/Colors.md new file mode 100644 index 0000000..91a696f --- /dev/null +++ b/Documentation/Appearance/Colors.md @@ -0,0 +1,18 @@ +# Colors + +| Property Name | Type | Description | Default Value | +| --- | --- | --- | --- | +| tint | Color | The main colors used in views provided by ChatUI. | Color(.systemBlue) | +| primary | Color | The primary label color. | Color.primary | +| secondary | Color | The secondary label color. | Color.secondary | +| background | Color | The background color. | Color(.systemBackground) | +| secondaryBackground | Color | The secondary background color. | Color(.secondarySystemBackground) | +| localMessageBackground | Color | The background color for local user's message body. | Color(.tintColor) | +| remoteMessageBackground | Color | The background color for remote user's message body. | Color(.secondarySystemBackground) | +| imagePlaceholder | Color | The color used in image placeholder. | Color(.secondarySystemBackground) | +| border | Color | The color used in border. | Color(.secondarySystemBackground) | +| disabled | Color | The color used for disabled states. | Color.secondary | +| error | Color | The color used for error states. | Color(.systemRed) | +| prominent | Color | The prominent color. This color is used for text on prominent buttons. | Color.white | +| link | Color | The link color. | Color(uiColor: .link) | +| prominentLink | Color | The link color that is used in prominent views such as local message body. | Color(uiColor: .systemYellow) | diff --git a/Documentation/Appearance/ImageScales.md b/Documentation/Appearance/ImageScales.md new file mode 100644 index 0000000..9830609 --- /dev/null +++ b/Documentation/Appearance/ImageScales.md @@ -0,0 +1,58 @@ +# Image Scales + +This Swift extension provides convenient properties to scale `Image` + views to predefined sizes. The `scale(_:contentMode:)` + method is used to resize an image or other view to a specific size while keeping its aspect ratio. + +## Properties + +| Property Name | Size | Content Mode | +| --- | --- | --- | +| xSmall | 16 x 16 | .fit | +| xSmall2 | 16 x 16 | .fill | +| small | 20 x 20 | .fit | +| small2 | 20 x 20 | .fill | +| medium | 24 x 24 | .fit | +| medium2 | 24 x 24 | .fill | +| large | 36 x 36 | .fit | +| large2 | 36 x 36 | .fill | +| xLarge | 48 x 48 | .fit | +| xLarge2 | 48 x 48 | .fill | +| xxLarge | 64 x 64 | .fit | +| xxLarge2 | 64 x 64 | .fill | +| xxxLarge | 90 x 90 | .fit | +| xxxLarge2 | 90 x 90 | .fill | + +## Method + +```swift +func scale(_ scale: CGSize, contentMode: ContentMode) -> some View + +``` + +**Description** + +Scales the view to the specified size while maintaining its aspect ratio. + +Use this method to resize an image or other view to a specific size while keeping its aspect ratio. + +**Parameters** + +| Parameter | Description | +| --- | --- | +| scale | The target size for the view, specified as a CGSize. | +| contentMode | The content mode to use when scaling the view. The default value is ContentMode.aspectFit. | + +**Return Value** + +A new view that scales the original view to the specified size. + +**Example Usage** + +```swift +Image("my-image") + .scale(CGSize(width: 100, height: 100), contentMode: .fill) +``` + +In this example, the Image view is scaled to a size of `100` points by `100` points while maintaining its aspect ratio. The `contentMode` parameter is set to `.fill`, which means that the image is stretched to fill the available space, possibly cutting off some of the edges. + diff --git a/Documentation/Appearance/Images.md b/Documentation/Appearance/Images.md new file mode 100644 index 0000000..f857d29 --- /dev/null +++ b/Documentation/Appearance/Images.md @@ -0,0 +1,37 @@ +# Images + +ChatUI provides image objects as an extension of the Image class in SwiftUI, where each image is created as a static variable with a default value being an image with a specific system name. These image names can be used to display icons, avatars, and other images. The names of these images can be used in the code to display the respective icon for various purposes in the user interface. + +| Property Name | Type | Default Value | Description | +| --- | --- | --- | --- | +| menu | Image | Image(systemName: "circle.grid.2x2.fill") | Icon for a menu | +| camera | Image | Image(systemName: "camera.fill") | Icon for a camera | +| photoLibrary | Image | Image(systemName: "photo") | Icon for a photo library | +| mic | Image | Image(systemName: "mic.fill") | Icon for a microphone | +| giphy | Image | Image(systemName: "face.smiling.fill") | Icon for GIPHY | +| send | Image | Image(systemName: "paperplane.fill") | Icon for sending a message | +| buttonHidden | Image | Image(systemName: "chevron.right") | Icon for a hidden button | +| directionDown | Image | Image(systemName: "chevron.down") | Icon for a downward direction | +| location | Image | Image(systemName: "location.fill") | Icon for a location | +| document | Image | Image(systemName: "paperclip") | Icon for a document | +| music | Image | Image(systemName: "music.note") | Icon for music | +| sending | Image | Image(systemName: "circle.dotted") | Icon for a message that is currently being sent | +| sent | Image | Image(systemName: "checkmark.circle") | Icon for a sent message | +| delivered | Image | Image(systemName: "checkmark.circle.fill") | Icon for a delivered message | +| failed | Image | Image(systemName: "exclamationmark.circle") | Icon for a failed message | +| downloadFailed | Image | Image(systemName: "icloud.slash") | Icon for a failed download | +| close | Image | Image(systemName: "xmark.circle.fill") | Icon for closing a window | +| flip | Image | Image(systemName: "arrow.triangle.2.circlepath") | Icon for flipping an object | +| delete | Image | Image(systemName: "trash") | Icon for deleting an object | +| pause | Image | Image(systemName: "pause.circle.fill") | Icon for pausing an activity | +| play | Image | Image(systemName: "play.circle.fill") | Icon for playing an activity | +| person | Image | Image(systemName: "person.crop.circle.fill") | Icon for a person | + +The example usage in the code demonstrates how to use these images to display the send icon, by making the icon resizable, setting its size, and clipping it to a circle shape. + +```swift +Image.send + .resizable() + .frame(width: 100, height: 100) + .clipShape(Circle()) +``` diff --git a/Documentation/Appearance/Typography.md b/Documentation/Appearance/Typography.md new file mode 100644 index 0000000..d3e30c0 --- /dev/null +++ b/Documentation/Appearance/Typography.md @@ -0,0 +1,9 @@ +# Typography + +| Property Name | Type | Description | Default Value | +| --- | --- | --- | --- | +| messageBody | Font | The font used in message's body. | .subheadline | +| caption | Font | The font used in additional minor information such as date. | .caption | +| footnote | Font | The font used in additional major information such as sender's name. | .footnote | +| title | Font | The font used in the title such as the title of the channel in ChannelInfoView. | .headline | +| subtitle | Font | The font used in the subtitle such as the subtitle of the channel in ChannelInfoView. | .footnote | diff --git a/Documentation/Chat_in_Channel/ChannelInfoView.md b/Documentation/Chat_in_Channel/ChannelInfoView.md new file mode 100644 index 0000000..6f10fbd --- /dev/null +++ b/Documentation/Chat_in_Channel/ChannelInfoView.md @@ -0,0 +1,2 @@ +# ChannelInfoView + diff --git a/Documentation/Chat_in_Channel/MessageField.md b/Documentation/Chat_in_Channel/MessageField.md new file mode 100644 index 0000000..1331f80 --- /dev/null +++ b/Documentation/Chat_in_Channel/MessageField.md @@ -0,0 +1,91 @@ +# MessageField + +The message field is a UI component for sending messages. + +## How to send a new message + +When creating a `MessageField`, you can provide an action for how to handle a new `MessageStyle` information in the `onSend` parameter. `MessageStyle` can contain different types of messages, such as text, media (photo, video, document, contact), and voice. + +```swift +MessageField { messageStyle in + viewModel.sendMessage($0) +} +``` + +### Supported message styles + +- [x] text +- [x] voice +- [x] photo library +- [x] giphy +- [x] location +- [ ] camera (*coming soon*) +- [ ] document (*coming soon*) +- [ ] contacts (*coming soon*) + +## Handling menu items + +```swift +MessageField(isMenuItemPresented: $isMenuItemPresented) { ... } + +if isMenuItemPresented { + MyMenuItemList() +} +``` + +## Sending location + +To send a location, you can use the `LocationSelector` component, which presents a UI for the user to select a location. When the user taps the send location button, the `onSend` action of the `MessageField` is called. + +> **NOTE:** +> +> If you want to use `sendMessagePublisher` instead `onSend`, please refer to [Sending a new message by using publisher](https://www.notion.so/ChatUI-ab3dddb98c44434d993c96ae9da6b929#d918e619224147958c840e678c93890a) + +```swift +@State private var showsLocationSelector: Bool = false + +var body: some View { + LocationSelector(isPresented: $showsLocationSelector) +} +``` + +## Sending a new message by using publisher + +```swift +public var sendMessagePublisher: PassthroughSubject +``` + +`sendMessagePublisher` is a Combine `Publisher` that passes `MessageStyle` object. + +### How to publish + +To publish a new message, you can create a new `MessageStyle` object and send it using `send(_:)`. + +```swift +let _ = Empty() + .sink( + receiveCompletion: { _ in + // Create `MessageStyle` object + let style = MessageStyle.text("{TEXT}") + // Publish the created style object via `send(_:)` + sendMessagePublisher.send(style) + }, + receiveValue: { _ in } + ) +``` + +### How to subscribe + +You can subscribe to `sendMessagePublisher` to handle new messages. + +```swift +.onReceive(sendMessagePublisher) { messageStyle in + // Handle `messageStyle` here (e.g., sending message with the style) +} +``` + +### Use cases +- rating system +- answering by defined message + +- - - diff --git a/Documentation/Chat_in_Channel/MessageList.md b/Documentation/Chat_in_Channel/MessageList.md new file mode 100644 index 0000000..7519bf5 --- /dev/null +++ b/Documentation/Chat_in_Channel/MessageList.md @@ -0,0 +1,37 @@ +# MessageList + +## Lists messages in row contents + +In the constructor, you can list message objects that conform to `MessageProtocol` to display messages using the `rowContent` parameter. + +All the body and row contents are flipped vertically so that new messages can be listed from the bottom. + +The messages are listed in the following order, depending on the `readReceipt` value of the `MessageProtocol`. For more details, please refer to `MessageProtocol/readReceipt` or `ReadReceipt`. + +sending → failed → sent → delivered → seen + +## Scrolls to bottom + +When a new message is sent or the scroll button is tapped, the view automatically scrolls to the bottom. You can also scroll the message list in other situations using the `scrollDownPublisher` by subscribing to it. See the following examples for how to use it. + +### How to publish + +```swift +let _ = Empty() + .sink( + receiveCompletion: { _ in + scrollDownPublisher.send(()) + }, + receiveValue: { _ in } + ) +``` + +### How to subscribe + +```swift +.onReceive(scrollDownPublisher) { _ in + withAnimation { + scrollView.scrollTo(id, anchor: .bottom) + } +} +``` diff --git a/Documentation/Chat_in_Channel/MessageRow.md b/Documentation/Chat_in_Channel/MessageRow.md new file mode 100644 index 0000000..00f2b97 --- /dev/null +++ b/Documentation/Chat_in_Channel/MessageRow.md @@ -0,0 +1,22 @@ +# MessageRow + +## Displays message content + +This is a view that is provided by default in ChatUI to display message information. + +It shows the following information: + +- Message content +- Message sent date +- Message sender information +- Message delivery status + +## Message Delivery Status + +The message delivery status can be one of the following: + +- sending +- failed +- sent +- delivered +- seen diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..8555418 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,23 @@ +{ + "pins" : [ + { + "identity" : "giphy-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Giphy/giphy-ios-sdk", + "state" : { + "revision" : "699483a3a2b534e9dc3a3ab85a9f7095e306bde1", + "version" : "2.2.2" + } + }, + { + "identity" : "libwebp-xcode", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SDWebImage/libwebp-Xcode", + "state" : { + "revision" : "4f52fc9b29600a03de6e05af16df0d694cb44301", + "version" : "1.2.4" + } + } + ], + "version" : 2 +} diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..d012ec3 --- /dev/null +++ b/Package.swift @@ -0,0 +1,32 @@ +// swift-tools-version: 5.7 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "ChatUI", + platforms: [.iOS(.v16)], + products: [ + // Products define the executables and libraries a package produces, and make them visible to other packages. + .library( + name: "ChatUI", + targets: ["ChatUI"]), + ], + dependencies: [ + // Dependencies declare other packages that this package depends on. + // .package(url: /* package url */, from: "1.0.0"), + .package(url: "https://github.com/Giphy/giphy-ios-sdk", .upToNextMajor(from: "2.1.3")) + ], + targets: [ + // Targets are the basic building blocks of a package. A target can define a module or a test suite. + // Targets can depend on other targets in this package, and on products in packages this package depends on. + .target( + name: "ChatUI", + dependencies: [ + .product(name: "GiphyUISDK", package: "giphy-ios-sdk") + ]), + .testTarget( + name: "ChatUITests", + dependencies: ["ChatUI"]), + ] +) diff --git a/README.md b/README.md index 05e1b7e..595bfb6 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,3 @@ -# ChatUI - ChatUI is an open-source Swift package that provides a simple and reliable solution for implementing chat interfaces using SwiftUI.

@@ -8,6 +6,25 @@ ChatUI is an open-source Swift package that provides a simple and reliable solut

+## **Overview** + +*written by ChatGPT* + +There are many companies that provide Chat SDK, such as Firebase, Sendbird, GetStream, and Zendesk. This means that the interface we use to implement chat functionality depends on our choice of SDK. While Apple's UI framework, SwiftUI, allows for incredibly flexible and fast UI design, there is a lack of available information on how to implement chat functionality, particularly when it comes to managing scrolling in message lists. To solve this problem, some Chat SDK companies offer their own Chat UI kits. However, since one UIKit only supports one SDK, there is no guarantee that a given UIKit will support the Chat SDK we are using, and switching to a different Chat SDK can create significant UI issues. + +Nevertheless, you know that different Chat SDKs essentially have the same meaning and essence despite different interface names and forms. If you conform to the protocols provided by ChatUI for the channels, messages, and users that we want to implement UI for, ChatUI can draw a SwiftUI-based chat UI based on this information. + +Although ChatUI currently offers very limited features, I’m confident that it can provide best practices for implementing chat interfaces using SwiftUI. Additionally, since ChatUI is an open source project, you can expand its capabilities and create a more impressive ChatUI together through contributions. I appreciate your interest. + +## **Contribution** + +I welcome and appreciate contributions from the community. If you find a bug, have a feature request, or want to contribute code, please submit an issue or a pull request on our GitHub repository freely. +> **IMPORTANT:** When you contribute code via pull request, please add the *executable* **previews** that conforms to `PreviewProvider`. + +## **License** + +ChatUI is released under the MIT license. See **[LICENSE](https://github.com/jaesung-0o0/ChatUI/blob/main/LICENSE)** for details. + ## **Installation** To use ChatUI in your project, follow these steps: @@ -27,11 +44,63 @@ import ChatUI You can then use ChatUI to implement chat interfaces in your SwiftUI views. Follow the guidelines in the ChatUI documentation to learn how to use the package. -## **Contribution** +## Key Functions -I welcome and appreciate contributions from the community. If you find a bug, have a feature request, or want to contribute code, please submit an issue or a pull request on our GitHub repository freely. -> **IMPORTANT:** When you contribute code via pull request, please add the *executable* **previews** that conforms to `PreviewProvider`. +### Chat in Channels -## **License** +#### ℹ️ Channel Info View +This is a view that displays the following channel information -ChatUI is released under the MIT license. See **[LICENSE](https://github.com/jaesung-0o0/ChatUI/blob/main/LICENSE)** for details. +[See documentation](/Documentation/Chat_in_Channel/ChannelInfoView.md) + +#### 🥞 Message List +This is a view that lists message objects. + +[See documentation](/Documentation/Chat_in_Channel/MessageList.md) + +#### 💬 Message Row +This is a view that is provided by default in ChatUI to display message information. + +[See documentation](/Documentation/Chat_in_Channel/MessageRow.md) + +#### ⌨️ Message Field + +The message field is a UI component for sending messages + +[See documentation](/Documentation/Chat_in_Channel/MessageField.md) + +### List Channels + +*Coming soon* + +### Appearances + +#### Appearance + +The `Appearance` struct represents a set of predefined appearances used in the app's user interface such as colors and typography. + +[See documentation](/Documentation/Appearance/Appearance.md) + +#### Colors + +The predefined colors used in the ChatUI. + +[See documentation](/Documentation/Appearance/Colors.md) + +#### Typography + +The predefined colors used in the ChatUI. + +[See documentation](/Documentation/Appearance/Typography.md) + +#### Images + +The predefined images used in the ChatUI as an extension of the `SwiftUI.Image`. + +[See documentation](/Documentation/Appearance/Images.md) + +#### Image Scales + +The predefined image scales used in the ChatUI. + +[See documentation](/Documentation/Appearance/ImageScales.md) diff --git a/Sources/ChatUI/Appearance/Appearance.ChatUI.swift b/Sources/ChatUI/Appearance/Appearance.ChatUI.swift new file mode 100644 index 0000000..dde77d7 --- /dev/null +++ b/Sources/ChatUI/Appearance/Appearance.ChatUI.swift @@ -0,0 +1,127 @@ +// +// Color.ChatUI.swift +// +// +// Created by Jaesung Lee on 2023/02/08. +// + +import SwiftUI + +extension EnvironmentValues { + public var appearance: Appearance { + get { self[AppearanceKey.self] } + set { self[AppearanceKey.self] = newValue } + } +} + +private struct AppearanceKey: EnvironmentKey { + static var defaultValue: Appearance = Appearance() +} + +/** + The set of predefined appearances used in the app's user interface such as colors and fonts. + + Use these colors to maintain consistency and familiarity in the user interface. + For example, use + - the ``Appearance/tint`` color for the main colors, + - the ``Appearance/background`` color for the view's background, + - the ``Appearance/prominent`` color for text on prominent buttons. + + **Example Usage** + + ```swift + @Environment(\.appearance) var appearance + + var body: some View { + Text("New Chat") + .font(appearance.title) + .foregroundColor(appearance.primary) + } + ``` + */ +public struct Appearance { + // MARK: Predefined Colors + /// The main colors used in views provided by ``ChatUI``. The default is `Color(.systemBlue)` + public let tint: Color + /// The primary label color. + public let primary: Color + /// The secondary label color. + public let secondary: Color + /// The background color. + public let background: Color + /// The secondary background color. + public let secondaryBackground: Color + /// The background color for local user's message body. + public let localMessageBackground: Color + /// The background color for remote user's message body. + public let remoteMessageBackground: Color + /// The color used in image placehoder. + public let imagePlaceholder: Color + /// The color used in border. The default is `secondaryBackground` + public let border: Color + /// The color used for disabled states. The default is `Color.secondary` + public let disabled: Color + /// The color used for error states. The default is `Color(.systemRed)` + public let error: Color + /// The prominent color. This color is used for text on prominent buttons. The default is `Color.white`. + public let prominent: Color + /// The link color. The default is `Color(uiColor: .link)`. + public let link: Color + /// The link color that is used in prominent views such as *local* message body. The default is `Color(uiColor: .systemYellow)`. + public let prominentLink: Color + + /// The font used in message's body + public let messageBody: Font + /// The font used in additional minor information such as date + public let caption: Font + /// The font used in additional major information such as sender's name. + public let footnote: Font + /// The font used in the title such as the title of the channel in ``ChannelInfoView`` + public let title: Font + /// The font used in the subtitle such as the subtitle of the channel in ``ChannelInfoView`` + public let subtitle: Font + + public init( + tint: Color = Color(.tintColor), + primary: Color = Color.primary, + secondary: Color = Color.secondary, + background: Color = Color(.systemBackground), + secondaryBackground: Color = Color(.secondarySystemBackground), + localMessageBackground: Color = Color(.tintColor), + remoteMessageBackground: Color = Color(.secondarySystemBackground), + imagePlaceholder: Color = Color(.secondarySystemBackground), + border: Color = Color(.secondarySystemBackground), + disabled: Color = Color.secondary, + error: Color = Color(.systemRed), + prominent: Color = Color.white, + link: Color = Color(uiColor: .link), + prominentLink: Color = Color(uiColor: .systemYellow), + messageBody: Font = .subheadline, + caption: Font = .caption, + footnote: Font = .footnote, + title: Font = .headline, + subtitle: Font = .footnote + ) { + self.tint = tint + self.primary = primary + self.secondary = secondary + self.background = background + self.secondaryBackground = secondaryBackground + self.localMessageBackground = localMessageBackground + self.remoteMessageBackground = remoteMessageBackground + self.imagePlaceholder = imagePlaceholder + self.border = border + self.disabled = disabled + self.error = error + self.prominent = prominent + self.link = link + self.prominentLink = prominentLink + + // Font + self.messageBody = messageBody + self.caption = caption + self.footnote = footnote + self.title = title + self.subtitle = subtitle + } +} diff --git a/Sources/ChatUI/Appearance/CGSize.ChatUI.swift b/Sources/ChatUI/Appearance/CGSize.ChatUI.swift new file mode 100644 index 0000000..c443cfd --- /dev/null +++ b/Sources/ChatUI/Appearance/CGSize.ChatUI.swift @@ -0,0 +1,34 @@ +// +// CGSize.ChatUI.swift +// +// +// Created by Jaesung Lee on 2023/02/19. +// + +import Foundation + +extension CGSize { + /// Sizes for images used in ``ChatUI``. + /// Use these values to ensure that images are displayed correctly in the chat user interface. + /// ```swift + /// Image.send + /// .scale(.Image.medium, contentMode: .fit) + /// ``` + public class Image { + /// 16 x 16 + public static var xSmall = CGSize(width: 16, height: 16) + /// 20 x 20 + public static var small = CGSize(width: 20, height: 20) + /// 24 x 24 + public static var medium = CGSize(width: 24, height: 24) + /// 36 x 36 + public static var large = CGSize(width: 36, height: 36) + /// 44 x 44 + public static var xLarge = CGSize(width: 48, height: 48) + /// 64 x 64 + public static var xxLarge = CGSize(width: 64, height: 64) + /// 90 x 90 + public static var xxxLarge = CGSize(width: 90, height: 90) + } +} + diff --git a/Sources/ChatUI/Appearance/Image.ChatUI.swift b/Sources/ChatUI/Appearance/Image.ChatUI.swift new file mode 100644 index 0000000..f509e1e --- /dev/null +++ b/Sources/ChatUI/Appearance/Image.ChatUI.swift @@ -0,0 +1,88 @@ +// +// Image.ChatUI.swift +// +// +// Created by Jaesung Lee on 2023/02/08. +// + +/// Defines images used in ``ChatUI``. +/// +/// Use these image names to display icons, avatars, and other images in the chat user interface. +/// +/// Example usage: +/// ``` +/// Image.send +/// .resizable() +/// .frame(width: 100, height: 100) +/// .clipShape(Circle()) +/// ``` + +import SwiftUI + +extension Image { + /// circle.grid.2x2.fill + public static var menu: Image = Image(systemName: "circle.grid.2x2.fill") + + /// camera.fill + public static var camera: Image = Image(systemName: "camera.fill") + + /// photo + public static var photoLibrary: Image = Image(systemName: "photo") + + /// mic.fill + public static var mic: Image = Image(systemName: "mic.fill") + + /// face.smiling.fill + public static var giphy: Image = Image(systemName: "face.smiling.fill") + + /// paperplane.fill + public static var send: Image = Image(systemName: "paperplane.fill") + + /// chevron.right + public static var buttonHidden: Image = Image(systemName: "chevron.right") + + /// chevron.down + public static var directionDown: Image = Image(systemName: "chevron.down") + + /// location.fill + public static var location: Image = Image(systemName: "location.fill") + + /// paperclip + public static var document: Image = Image(systemName: "paperclip") + + /// music.note + public static var music: Image = Image(systemName: "music.note") + + /// circle.dotted + public static var sending: Image = Image(systemName: "circle.dotted") + + /// checkmark.circle + public static var sent: Image = Image(systemName: "checkmark.circle") + + /// checkmark.circle.fill + public static var delivered: Image = Image(systemName: "checkmark.circle.fill") + + /// exclamationmark.circle + public static var failed: Image = Image(systemName: "exclamationmark.circle") + + /// icloud.slash + public static var downloadFailed: Image = Image(systemName: "icloud.slash") + + /// xmark.circle.fill + public static var close: Image = Image(systemName: "xmark.circle.fill") + + /// arrow.triangle.2.circlepath + public static var flip: Image = Image(systemName: "arrow.triangle.2.circlepath") + + /// trash + public static var delete: Image = Image(systemName: "trash") + + /// pause.circle.fill + public static var pause: Image = Image(systemName: "pause.circle.fill") + + /// play.circle.fill + public static var play: Image = Image(systemName: "play.circle.fill") + + /// person.crop.circle.fill + public static var person: Image = Image(systemName: "person.crop.circle.fill") +} diff --git a/Sources/ChatUI/Appearance/ImageScale.ChatUI.swift b/Sources/ChatUI/Appearance/ImageScale.ChatUI.swift new file mode 100644 index 0000000..5d2f9e4 --- /dev/null +++ b/Sources/ChatUI/Appearance/ImageScale.ChatUI.swift @@ -0,0 +1,125 @@ +// +// ImageScale.ChatUI.swift +// +// +// Created by Jaesung Lee on 2023/02/12. +// + +import SwiftUI + +extension Image { + /** + Scales the view to the specified size while maintaining its aspect ratio. + + Use this method to resize an image or other view to a specific size while keeping its aspect ratio. + + - Parameters: + - scale: The target size for the view, specified as a `CGSize`. + - contentMode: The content mode to use when scaling the view. The default value is `ContentMode.aspectFit`. + + - Returns: A new view that scales the original view to the specified size. + + **Example usage:** + + ```swift + Image("my-image") + .scale(CGSize(width: 100, height: 100), contentMode: .fill) + ``` + In this example, the Image view is scaled to a size of `100` points by `100` points while maintaining its aspect ratio. The `contentMode` parameter is set to `.fill`, which means that the image is stretched to fill the available space, possibly cutting off some of the edges. + + - Note: The `frame(width:height:)` modifier is used to set the size of the scaled view, and the `clipped()` modifier is used to ensure that the view does not extend beyond its frame. + + - SeeAlso: `resizable()`, `aspectRatio(contentMode:)`, `frame(width:height:)` + */ + public func scale(_ scale: CGSize, contentMode: ContentMode) -> some View { + self + .resizable() + .aspectRatio(contentMode: contentMode) + .frame(width: scale.width, height: scale.height) + .clipped() + } + + /// 16 x 16, `.fit` + public var xSmall: some View { + self + .scale(.Image.xSmall, contentMode: .fit) + } + + /// 16 x 16, `.fill` + public var xSmall2: some View { + self + .scale(.Image.xSmall, contentMode: .fill) + } + + /// 20 x 20, `.fit` + public var small: some View { + self + .scale(.Image.small, contentMode: .fit) + } + + /// 20 x 20, `.fill` + public var small2: some View { + self + .scale(.Image.small, contentMode: .fill) + } + + /// 24 x 24, `.fit` + public var medium: some View { + self + .scale(.Image.medium, contentMode: .fit) + } + + /// 24 x 24, `.fill` + public var medium2: some View { + self + .scale(.Image.medium, contentMode: .fill) + } + + /// 36 x 36, `.fit` + public var large: some View { + self + .scale(.Image.large, contentMode: .fit) + } + + /// 36 x 36, `.fill` + public var large2: some View { + self + .scale(.Image.large, contentMode: .fill) + } + + /// 48 x 48, `.fit` + public var xLarge: some View { + self + .scale(.Image.xLarge, contentMode: .fit) + } + + /// 48 x 48, `.fill` + public var xLarge2: some View { + self + .scale(.Image.xLarge, contentMode: .fill) + } + + /// 64 x 64, `.fit` + public var xxLarge: some View { + self + .scale(.Image.xxLarge, contentMode: .fit) + } + + /// 64 x 64, `.fill` + public var xxLarge2: some View { + self + .scale(.Image.xxLarge, contentMode: .fill) + } + + /// 90 x 90, `.fit` + public var xxxLarge: some View { + self + .scale(.Image.xxxLarge, contentMode: .fit) + } + + /// 90 x 90, `.fill` + public var xxxLarge2: some View { + self + .scale(.Image.xxxLarge, contentMode: .fill) + } +} diff --git a/Sources/ChatUI/Appearance/String.ChatUI.swift b/Sources/ChatUI/Appearance/String.ChatUI.swift new file mode 100644 index 0000000..a4018f9 --- /dev/null +++ b/Sources/ChatUI/Appearance/String.ChatUI.swift @@ -0,0 +1,18 @@ +// +// String.ChatUI.swift +// +// +// Created by Jaesung Lee on 2023/02/09. +// + +import Foundation + +extension String { + public class MessageField { + public static var placeholder: String = "Aa" + } + + public class Message { + public static var failedPhoto: String = "Couldn't Load Image " + } +} diff --git a/Sources/ChatUI/ChatInChannel/ChannelInfoView.swift b/Sources/ChatUI/ChatInChannel/ChannelInfoView.swift new file mode 100644 index 0000000..fed9d06 --- /dev/null +++ b/Sources/ChatUI/ChatInChannel/ChannelInfoView.swift @@ -0,0 +1,50 @@ +// +// ChannelInfoView.swift +// +// +// Created by Jaesung Lee on 2023/02/09. +// + +import SwiftUI + +public struct ChannelInfoView: View { + @Environment(\.appearance) var appearance + + let imageURL: URL? + let title: String + let subtitle: String + + public var body: some View { + HStack { + AsyncImage(url: imageURL) { image in + image.large2 + .clipShape(Circle()) + .padding(1) + .background { + appearance.border + .clipShape(Circle()) + } + } placeholder: { + Image.person.large2 + .foregroundColor(appearance.secondary) + .clipShape(Circle()) + } + + VStack(alignment: .leading) { + Text(title) + .font(appearance.title) + .foregroundColor(appearance.primary) + + Text(subtitle) + .font(appearance.subtitle) + .foregroundColor(appearance.secondary) + } + } + } + + public init(imageURL: URL?, title: String, subtitle: String) { + self.imageURL = imageURL + self.title = title + self.subtitle = subtitle + } +} diff --git a/Sources/ChatUI/ChatInChannel/ChannelStack.swift b/Sources/ChatUI/ChatInChannel/ChannelStack.swift new file mode 100644 index 0000000..f03b430 --- /dev/null +++ b/Sources/ChatUI/ChatInChannel/ChannelStack.swift @@ -0,0 +1,76 @@ +// +// ChannelStack.swift +// +// +// Created by Jaesung Lee on 2023/02/09. +// + +import SwiftUI + +// TODO: VStack 감싸기 +/** + ```swift + ChannelStack(channel, spacing: 0) { + MessageList() { ... } + + MessageField { ... } + } + .channelInfoBar { ... } + ``` + */ + +/** + The *vertical* stack view that provides ``ChannelInfoView`` as a `ToolbarItem`. + + ```swift + ChannelStack(channel) { + MessageList(messages) { + MessageRow(message) + } + + MessageField(isMenuItemPresented: $isPresented) { + sendMessage(style: $0) + } + } + ``` + */ +public struct ChannelStack: View { + @EnvironmentObject private var configuration: ChatConfiguration + + let channel: C + let content: () -> Content + + public var body: some View { + VStack(spacing: 0) { + content() + } + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + ChannelInfoView( + imageURL: channel.imageURL, + title: channel.name, + subtitle: channel.id + ) + } + } + } + + public init( + _ channel: C, + @ViewBuilder content: @escaping () -> Content + ) { + self.channel = channel + self.content = content + } +} + +/** + VStack { + MessageList(..) { ... } + + MessageField { .. } + } + .channelInfoBar { + + } + */ diff --git a/Sources/ChatUI/ChatInChannel/MessageField/Camera/DataModel/Camera.AVCapturePhotoCaptureDelegate.swift b/Sources/ChatUI/ChatInChannel/MessageField/Camera/DataModel/Camera.AVCapturePhotoCaptureDelegate.swift new file mode 100644 index 0000000..af73eb6 --- /dev/null +++ b/Sources/ChatUI/ChatInChannel/MessageField/Camera/DataModel/Camera.AVCapturePhotoCaptureDelegate.swift @@ -0,0 +1,19 @@ +// +// Camera.AVCapturePhotoCaptureDelegate.swift +// +// +// Created by Jaesung Lee on 2023/02/12. +// + +import Combine +import AVFoundation + +extension Camera: AVCapturePhotoCaptureDelegate { + func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) { + guard let data = photo.fileDataRepresentation() else { return } + let _ = Empty() + .sink { _ in + capturedItemPublisher.send(.photo(data)) + } receiveValue: { _ in } + } +} diff --git a/Sources/ChatUI/ChatInChannel/MessageField/Camera/DataModel/Camera.AVCaptureVideoDataOutputSampleBufferDelegate.swift b/Sources/ChatUI/ChatInChannel/MessageField/Camera/DataModel/Camera.AVCaptureVideoDataOutputSampleBufferDelegate.swift new file mode 100644 index 0000000..15e6079 --- /dev/null +++ b/Sources/ChatUI/ChatInChannel/MessageField/Camera/DataModel/Camera.AVCaptureVideoDataOutputSampleBufferDelegate.swift @@ -0,0 +1,22 @@ +// +// Camera.AVCaptureVideoDataOutputSampleBufferDelegate.swift +// +// +// Created by Jaesung Lee on 2023/02/12. +// + +import AVFoundation +import CoreImage + +extension Camera: AVCaptureVideoDataOutputSampleBufferDelegate { + func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) { + guard let pixelBuffer = sampleBuffer.imageBuffer else { return } + + if connection.isVideoOrientationSupported, + let videoOrientation = videoOrientationFor(deviceOrientation) { + connection.videoOrientation = videoOrientation + } + +// selectedVideoPreviewStream?(CIImage(cvPixelBuffer: pixelBuffer)) + } +} diff --git a/Sources/ChatUI/ChatInChannel/MessageField/Camera/DataModel/Camera.swift b/Sources/ChatUI/ChatInChannel/MessageField/Camera/DataModel/Camera.swift new file mode 100644 index 0000000..63e70c5 --- /dev/null +++ b/Sources/ChatUI/ChatInChannel/MessageField/Camera/DataModel/Camera.swift @@ -0,0 +1,299 @@ +// +// Camera.swift +// +// +// Created by Jaesung Lee on 2023/02/12. +// + +import UIKit +import SwiftUI +import AVFoundation + +// TODO: remove +import Combine + +class Camera: NSObject, ObservableObject { + private let captureSession = AVCaptureSession() + private var isCaptureSessionConfigured = false + private var deviceInput: AVCaptureDeviceInput? + private var photoOutput: AVCapturePhotoOutput? + private var videoOutput: AVCaptureVideoDataOutput? + private var sessionQueue: DispatchQueue = DispatchQueue(label: "session queue") + + private var allCaptureDevices: [AVCaptureDevice] { + AVCaptureDevice.DiscoverySession( + deviceTypes: [ + .builtInTrueDepthCamera, .builtInDualCamera, .builtInDualWideCamera, .builtInWideAngleCamera, .builtInDualWideCamera + ], + mediaType: .video, + position: .unspecified + ) + .devices + } + + private var frontCaptureDevices: [AVCaptureDevice] { + allCaptureDevices + .filter { $0.position == .front } + } + + private var backCaptureDevices: [AVCaptureDevice] { + allCaptureDevices + .filter { $0.position == .back } + } + + private var captureDevices: [AVCaptureDevice] { + var devices = [AVCaptureDevice]() + #if os(macOS) || (os(iOS) && targetEnvironment(macCatalyst)) + devices += allCaptureDevices + #else + if let backDevice = backCaptureDevices.first { + devices += [backDevice] + } + if let frontDevice = frontCaptureDevices.first { + devices += [frontDevice] + } + #endif + return devices + } + + private var availableCaptureDevices: [AVCaptureDevice] { + captureDevices + .filter( { $0.isConnected } ) + .filter( { !$0.isSuspended } ) + } + + private var captureDevice: AVCaptureDevice? { + didSet { + guard let captureDevice = captureDevice else { return } + sessionQueue.async { + self.updateSessionForCaptureDevice(captureDevice) + } + } + } + + var isRunning: Bool { captureSession.isRunning } + + var isUsingFrontCaptureDevice: Bool { + guard let captureDevice = captureDevice else { return false } + return frontCaptureDevices.contains(captureDevice) + } + + var isUsingBackCaptureDevice: Bool { + guard let captureDevice = captureDevice else { return false } + return backCaptureDevices.contains(captureDevice) + } + + override init() { + super.init() + + captureDevice = availableCaptureDevices.first ?? AVCaptureDevice.default(for: .video) + } + + private func configureCaptureSession(completionHandler: (_ success: Bool) -> Void) { + var success = false + + self.captureSession.beginConfiguration() + + defer { + self.captureSession.commitConfiguration() + completionHandler(success) + } + + guard + let captureDevice = captureDevice, + let deviceInput = try? AVCaptureDeviceInput(device: captureDevice) + else { return } + + let photoOutput = AVCapturePhotoOutput() + + captureSession.sessionPreset = AVCaptureSession.Preset.photo + + let videoOutput = AVCaptureVideoDataOutput() + videoOutput.setSampleBufferDelegate(self, queue: DispatchQueue(label: "VideoDataOutputQueue")) + + guard captureSession.canAddInput(deviceInput) else { return } + guard captureSession.canAddOutput(photoOutput) else { return } + guard captureSession.canAddOutput(videoOutput) else { return } + + captureSession.addInput(deviceInput) + captureSession.addOutput(photoOutput) + captureSession.addOutput(videoOutput) + + self.deviceInput = deviceInput + self.photoOutput = photoOutput + self.videoOutput = videoOutput + + photoOutput.isHighResolutionCaptureEnabled = true + photoOutput.maxPhotoQualityPrioritization = .quality + + updateVideoOutputConnection() + + isCaptureSessionConfigured = true + + success = true + } + + private func checkAuthorization() async -> Bool { + switch AVCaptureDevice.authorizationStatus(for: .video) { + case .authorized: + return true + case .notDetermined: + sessionQueue.suspend() + let status = await AVCaptureDevice.requestAccess(for: .video) + sessionQueue.resume() + return status + default: + return false + } + } + + private func deviceInputFor(device: AVCaptureDevice?) -> AVCaptureDeviceInput? { + guard let validDevice = device else { return nil } + do { return try AVCaptureDeviceInput(device: validDevice) } + catch { return nil } + } + + private func updateSessionForCaptureDevice(_ captureDevice: AVCaptureDevice) { + guard isCaptureSessionConfigured else { return } + + captureSession.beginConfiguration() + defer { captureSession.commitConfiguration() } + + for input in captureSession.inputs { + if let deviceInput = input as? AVCaptureDeviceInput { + captureSession.removeInput(deviceInput) + } + } + + if let deviceInput = deviceInputFor(device: captureDevice) { + if !captureSession.inputs.contains(deviceInput), captureSession.canAddInput(deviceInput) { + captureSession.addInput(deviceInput) + } + } + + updateVideoOutputConnection() + } + + private func updateVideoOutputConnection() { + if let videoOutput = videoOutput, let videoOutputConnection = videoOutput.connection(with: .video) { + if videoOutputConnection.isVideoMirroringSupported { + videoOutputConnection.isVideoMirrored = isUsingFrontCaptureDevice + } + } + } + + func start() async { + let authorized = await checkAuthorization() + guard authorized else { return } + + if isCaptureSessionConfigured { + if !captureSession.isRunning { + sessionQueue.async { [self] in + self.captureSession.startRunning() + } + } + return + } + + sessionQueue.async { [self] in + self.configureCaptureSession { success in + guard success else { return } + self.captureSession.startRunning() + } + } + } + + func stop() { + guard isCaptureSessionConfigured else { return } + + if captureSession.isRunning { + sessionQueue.async { + self.captureSession.stopRunning() + } + } + } + + func switchCaptureDevice() { + if let captureDevice = captureDevice, let index = availableCaptureDevices.firstIndex(of: captureDevice) { + let nextIndex = (index + 1) % availableCaptureDevices.count + self.captureDevice = availableCaptureDevices[nextIndex] + } else { + self.captureDevice = AVCaptureDevice.default(for: .video) + } + } + + var deviceOrientation: UIDeviceOrientation { + var orientation = UIDevice.current.orientation + if orientation == UIDeviceOrientation.unknown { + orientation = UIScreen.main.orientation + } + return orientation + } + + func videoOrientationFor(_ deviceOrientation: UIDeviceOrientation) -> AVCaptureVideoOrientation? { + switch deviceOrientation { + case .portrait: return AVCaptureVideoOrientation.portrait + case .portraitUpsideDown: return AVCaptureVideoOrientation.portraitUpsideDown + case .landscapeLeft: return AVCaptureVideoOrientation.landscapeRight + case .landscapeRight: return AVCaptureVideoOrientation.landscapeLeft + default: return nil + } + } + + func takePhoto() { + // TODO: remove + if let data = (try? Data(contentsOf: URL(string: "https://picsum.photos/220")!)) { + let _ = Empty() + .sink { _ in + capturedItemPublisher.send(.photo(data)) + } receiveValue: { _ in } + } + + guard let photoOutput = self.photoOutput else { return } + + sessionQueue.async { + + var photoSettings = AVCapturePhotoSettings() + + if photoOutput.availablePhotoCodecTypes.contains(.hevc) { + photoSettings = AVCapturePhotoSettings(format: [AVVideoCodecKey: AVVideoCodecType.hevc]) + } + + let isFlashAvailable = self.deviceInput?.device.isFlashAvailable ?? false + photoSettings.flashMode = isFlashAvailable ? .auto : .off + photoSettings.isHighResolutionPhotoEnabled = true + if let previewPhotoPixelFormatType = photoSettings.availablePreviewPhotoPixelFormatTypes.first { + photoSettings.previewPhotoFormat = [kCVPixelBufferPixelFormatTypeKey as String: previewPhotoPixelFormatType] + } + photoSettings.photoQualityPrioritization = .balanced + + if let photoOutputVideoConnection = photoOutput.connection(with: .video) { + if photoOutputVideoConnection.isVideoOrientationSupported, + let videoOrientation = self.videoOrientationFor(self.deviceOrientation) { + photoOutputVideoConnection.videoOrientation = videoOrientation + } + } + + photoOutput.capturePhoto(with: photoSettings, delegate: self) + } + } +} + + +fileprivate extension UIScreen { + + var orientation: UIDeviceOrientation { + let point = coordinateSpace.convert(CGPoint.zero, to: fixedCoordinateSpace) + if point == CGPoint.zero { + return .portrait + } else if point.x != 0 && point.y != 0 { + return .portraitUpsideDown + } else if point.x == 0 && point.y != 0 { + return .landscapeRight //.landscapeLeft + } else if point.x != 0 && point.y == 0 { + return .landscapeLeft //.landscapeRight + } else { + return .unknown + } + } +} diff --git a/Sources/ChatUI/ChatInChannel/MessageField/Camera/View/CameraField.swift b/Sources/ChatUI/ChatInChannel/MessageField/Camera/View/CameraField.swift new file mode 100644 index 0000000..397c442 --- /dev/null +++ b/Sources/ChatUI/ChatInChannel/MessageField/Camera/View/CameraField.swift @@ -0,0 +1,104 @@ +// +// CameraField.swift +// +// +// Created by Jaesung Lee on 2023/02/12. +// + +import SwiftUI +import Combine + +public struct CameraField: View { + @Environment(\.appearance) var appearance + + @State private var isCameraViewPresented: Bool = true + @State private var capturedItem: CapturedItemView.CaptureType? + + @Binding var isPresented: Bool + + public var body: some View { + VStack { + if let capturedItem = capturedItem { +// if let imageData = dataModel.capturedPhotoData?.fileDataRepresentation() { + CapturedItemView(itemType: capturedItem) + .frame(width: UIScreen.main.bounds.width) + } else { + appearance.secondary + .overlay { + VStack { + Image.downloadFailed.xLarge + .clipped() + + Text(String.Message.failedPhoto) + .font(appearance.footnote.bold()) + } + .foregroundColor(appearance.secondary) + } + .frame( + width: UIScreen.main.bounds.width, + height: UIScreen.main.bounds.width + ) + } + + HStack { + Button(action: retake) { + Image.camera.medium + } + .tint(appearance.tint) + .frame(width: 36, height: 36) + + Spacer() + + Button(action: send) { + HStack { + Image.send.xSmall + + Text("Send") + .font(appearance.footnote.bold()) + } + .frame(height: 24) + .foregroundColor(.white) + .padding(.vertical, 6) + .padding(.horizontal, 18) + .background { + appearance.tint + .clipShape(Capsule()) + } + } + } + .padding(16) + } + .background { Color(.systemBackground) } + .fullScreenCover(isPresented: $isCameraViewPresented) { + CameraView { + isPresented = false + } + } + .onReceive(capturedItemPublisher) { capturedItem in + self.capturedItem = capturedItem + self.isCameraViewPresented = false + } + } + + func retake() { + isCameraViewPresented = true + } + + func send() { + let _ = Empty() + .sink { _ in + guard let capturedItem = capturedItem else { return } + let style: MessageStyle + switch capturedItem { + case .photo(let data): + style = MessageStyle.media(.photo(data)) + case .video(let data): + style = MessageStyle.media(.video(data)) + } + sendMessagePublisher.send(style) + } receiveValue: { _ in } + withAnimation { + isPresented = false + } + } +} diff --git a/Sources/ChatUI/ChatInChannel/MessageField/Camera/View/CameraView.swift b/Sources/ChatUI/ChatInChannel/MessageField/Camera/View/CameraView.swift new file mode 100644 index 0000000..390885c --- /dev/null +++ b/Sources/ChatUI/ChatInChannel/MessageField/Camera/View/CameraView.swift @@ -0,0 +1,64 @@ +// +// CameraView.swift +// +// +// Created by Jaesung Lee on 2023/02/12. +// + +import SwiftUI +import CoreImage +import AVFoundation + +public struct CameraView: View { + @Environment(\.appearance) var appearance + @Environment(\.dismiss) private var dismiss + + @StateObject private var dataModel = Camera() + + let onDismiss: () -> Void + + public var body: some View { + VStack() { + HStack { + Button(action: { + dismiss() + onDismiss() + }) { + Image.close.medium + .foregroundColor(appearance.prominent) + } + + Spacer() + + Button(action: dataModel.switchCaptureDevice) { + Image.flip.medium + .foregroundColor(appearance.prominent) + } + } + + Spacer() + + Button(action: dataModel.takePhoto) { + ZStack { + Circle() + .strokeBorder(appearance.prominent, lineWidth: 3) + .frame(width: 62, height: 62) + Circle() + .fill(appearance.prominent) + .frame(width: 50, height: 50) + } + } + + } + .buttonStyle(.plain) + .padding(.vertical, 64) + .padding(.horizontal, 16) + .background { Color.black } + .ignoresSafeArea() + .task { await dataModel.start() } + } + + public init(onDismiss: @escaping () -> Void) { + self.onDismiss = onDismiss + } +} diff --git a/Sources/ChatUI/ChatInChannel/MessageField/Camera/View/CapturedItemView.swift b/Sources/ChatUI/ChatInChannel/MessageField/Camera/View/CapturedItemView.swift new file mode 100644 index 0000000..e2cc5b1 --- /dev/null +++ b/Sources/ChatUI/ChatInChannel/MessageField/Camera/View/CapturedItemView.swift @@ -0,0 +1,48 @@ +// +// CapturedItemView.swift +// +// +// Created by Jaesung Lee on 2023/02/12. +// + +import SwiftUI +import Combine + +public struct CapturedItemView: View { + @Environment(\.appearance) var appearance + + let itemType: CaptureType + + public enum CaptureType { + case photo(_ data: Data) + case video(_ data: Data) + } + + public var body: some View { + switch itemType { + case .photo(let data): + if let uiImage = UIImage(data: data) { + Image(uiImage: uiImage) + .resizable() + .scaledToFill() + } else { + failedImage + } + case .video(let data): + failedImage + } + } + + var failedImage: some View { + Color(uiColor: .secondarySystemBackground) + .overlay { + VStack { + Image.downloadFailed.xLarge + + Text(String.Message.failedPhoto) + .font(appearance.footnote.bold()) + } + .foregroundColor(appearance.secondary) + } + } +} diff --git a/Sources/ChatUI/ChatInChannel/MessageField/Giphy/View/GiphyMediaView.swift b/Sources/ChatUI/ChatInChannel/MessageField/Giphy/View/GiphyMediaView.swift new file mode 100644 index 0000000..b8b0a2e --- /dev/null +++ b/Sources/ChatUI/ChatInChannel/MessageField/Giphy/View/GiphyMediaView.swift @@ -0,0 +1,34 @@ +// +// GiphyMediaView.swift +// +// +// Created by Jaesung Lee on 2023/02/11. +// + +import SwiftUI +import GiphyUISDK + +/// The view that corresponds to `GPHMediaView` which shows `GPHMedia`. This view is used in ``GiphyStyleView`` for GIF message. +/// - NOTE: It's referred to [github.com/Giphy/giphy-ios-sdk](https://github.com/Giphy/giphy-ios-sdk/blob/main/Docs.md#gphmediaview) +struct GiphyMediaView: UIViewRepresentable { + let gifID: String + @State private var media: GPHMedia? + @Binding var aspectRatio: CGFloat + + func makeUIView(context: Context) -> GPHMediaView { + let mediaView = GPHMediaView() + GiphyCore.shared.gifByID(gifID) { (response, error) in + if let media = response?.data { + DispatchQueue.main.sync { [self] in + self.aspectRatio = media.aspectRatio + self.media = media + } + } + } + return mediaView + } + + func updateUIView(_ uiView: GPHMediaView, context: Context) { + uiView.media = media + } +} diff --git a/Sources/ChatUI/ChatInChannel/MessageField/Giphy/View/GiphyPicker.swift b/Sources/ChatUI/ChatInChannel/MessageField/Giphy/View/GiphyPicker.swift new file mode 100644 index 0000000..a5ee75e --- /dev/null +++ b/Sources/ChatUI/ChatInChannel/MessageField/Giphy/View/GiphyPicker.swift @@ -0,0 +1,94 @@ +// +// GiphyPicker.swift +// +// +// Created by Jaesung Lee on 2023/02/11. +// + +import UIKit +import SwiftUI +import Combine +import GiphyUISDK + +/** + Picker for GIF provided by Giphy. + + - NOTE: It's referred to [github.com/Giphy/giphy-ios-sdk/issues/44](https://github.com/Giphy/giphy-ios-sdk/issues/44#issuecomment-1345202630) + + - IMPORTANT: When a GIF media is selected, ``sendMessagePublisher`` delivers the ID of the media via ``MessageStyle/media(_:)`` as an associated value. + + ```swift + @EnvironmentObject var configuration: ChatConfiguration + + ... + if let giphyKey = configuration.giphyKey { + GiphyPicker(giphyKey: giphyKey) + } + ``` + */ +public struct GiphyPicker: UIViewControllerRepresentable { + @Environment(\.colorScheme) var colorScheme + @Environment(\.dismiss) private var dismiss + + let giphyKey: String + + public func makeUIViewController(context: Context) -> GiphyViewController { + Giphy.configure(apiKey: giphyKey) + + let controller = GiphyViewController() + controller.swiftUIEnabled = true + controller.mediaTypeConfig = [.gifs, .stickers, .recents] + controller.delegate = context.coordinator + controller.navigationController?.isNavigationBarHidden = true + controller.navigationController?.setNavigationBarHidden(true, animated: false) + + GiphyViewController.trayHeightMultiplier = 1.0 + + controller.theme = GPHTheme( + type: colorScheme == .light + ? .lightBlur + : .darkBlur + ) + + return controller + } + + public func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) { + + } + + public func makeCoordinator() -> Coordinator { + return GiphyPicker.Coordinator(parent: self) + } + + public class Coordinator: NSObject, GiphyDelegate { + + var parent: GiphyPicker + + init(parent: GiphyPicker) { + self.parent = parent + } + + public func didDismiss(controller: GiphyViewController?) { + self.parent.dismiss() + } + + public func didSelectMedia(giphyViewController: GiphyViewController, media: GPHMedia) { + // media.id + DispatchQueue.main.async { + let _ = Empty() + .sink( + receiveCompletion: { _ in + let style = MessageStyle.media( + .gif(media.id) + ) + sendMessagePublisher.send(style) + }, + receiveValue: { _ in } + ) + + self.parent.dismiss() + } + } + } +} diff --git a/Sources/ChatUI/ChatInChannel/MessageField/Location/DataModel/LocationModel.swift b/Sources/ChatUI/ChatInChannel/MessageField/Location/DataModel/LocationModel.swift new file mode 100644 index 0000000..fd89857 --- /dev/null +++ b/Sources/ChatUI/ChatInChannel/MessageField/Location/DataModel/LocationModel.swift @@ -0,0 +1,118 @@ +// +// LocationModel.swift +// +// +// Created by Jaesung Lee on 2023/02/11. +// + +import MapKit +import SwiftUI +import Combine + +class LocationModel: NSObject, ObservableObject { + let manager = CLLocationManager() + + enum TrackStatus { + case none + case notAllowed + case tracking + case tracked + } + + // TODO: change to `false` + @Published var locationTrackingStatus: TrackStatus = .none + @Published var coordinateRegion: MKCoordinateRegion = .init( + center: .init(latitude: 37.57827, longitude: 126.97695), + span: .init(latitudeDelta: 0.005, longitudeDelta: 0.005) + ) + + // MARK: Search + @Published var searchText: String = "" + @Published var searchedResults: [MKMapItem] = [] + var searchPublisher: AnyCancellable? + var searchResultPublisher: AnyCancellable? + + let fixedRegion = MKCoordinateRegion( + center: .init(latitude: 37.57827, longitude: 126.97695), + span: .init(latitudeDelta: 0.005, longitudeDelta: 0.005) + ) + + override init() { + super.init() + + manager.delegate = self + subscribeSearchPublisher() + subscribesSearchResultPublisher() + } + + deinit { + searchPublisher?.cancel() + searchResultPublisher?.cancel() + } +} + +// MARK: - CLLocationManagerDelegate +extension LocationModel: CLLocationManagerDelegate { + func requestLocation() { + withAnimation { + locationTrackingStatus = .tracking + } + manager.requestLocation() + } + + func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + // Set up region once. + guard locationTrackingStatus != .tracked else { return } + guard let location = locations.first?.coordinate else { return } + coordinateRegion = .init( + center: location, + span: .init(latitudeDelta: 0.005, longitudeDelta: 0.005) + ) + locationTrackingStatus = .tracked + } + + func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { + print("🧭 [Location] error: \(error.localizedDescription)") + } + + func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) { + if status == .authorizedWhenInUse { + requestLocation() + } else { + locationTrackingStatus = .notAllowed + } + } +} + +extension LocationModel { + func subscribeSearchPublisher() { + searchPublisher = $searchText + .debounce(for: 0.5, scheduler: RunLoop.main) + .compactMap { $0 } + .sink(receiveValue: { [weak self] (searchText) in + self?.searchedResults = [] + self?.search(searchText) + }) + } + + func subscribesSearchResultPublisher() { + searchResultPublisher = locationSearchResultPublisher + .sink(receiveValue: { [weak self] items in + self?.searchedResults = items + }) + } + + func search(_ text: String) { + let request = MKLocalSearch.Request() + request.naturalLanguageQuery = searchText + request.pointOfInterestFilter = .includingAll + request.resultTypes = [.pointOfInterest] + request.region = coordinateRegion + let search = MKLocalSearch(request: request) + + search.start { (response, _) in + guard let response = response else { return } + locationSearchResultPublisher.send(response.mapItems) + } + } +} diff --git a/Sources/ChatUI/ChatInChannel/MessageField/Location/View/LocationSelector.swift b/Sources/ChatUI/ChatInChannel/MessageField/Location/View/LocationSelector.swift new file mode 100644 index 0000000..fad3c53 --- /dev/null +++ b/Sources/ChatUI/ChatInChannel/MessageField/Location/View/LocationSelector.swift @@ -0,0 +1,186 @@ +// +// LocationSelector.swift +// +// +// Created by Jaesung Lee on 2023/02/11. +// + +import MapKit +import SwiftUI +import Combine +import CoreLocationUI + +public struct LocationSelector: View { + @Environment(\.appearance) var appearance + + @StateObject var dataModel = LocationModel() + @Binding var isPresented: Bool + + let fade = Gradient(colors: [Color.black, Color.clear]) + + public var body: some View { + switch dataModel.locationTrackingStatus { + case .none: + EmptyView() + case .tracked: + VStack(spacing: 0) { + ZStack { + Map(coordinateRegion: $dataModel.coordinateRegion) + + SendLocationButton(action: send) + + Circle() + .frame(width: 8, height: 8) + .foregroundColor(appearance.tint) + } + + // Search results + if !dataModel.searchedResults.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack { + ForEach(dataModel.searchedResults, id: \.self) { resultItem in + Button { + dataModel.coordinateRegion.center = resultItem.placemark.coordinate + } label: { + VStack(alignment: .leading) { + Text(resultItem.name ?? "") + .foregroundColor(.primary) + .font(.subheadline) + + Text( + resultItem.placemark.subLocality + ?? resultItem.placemark.locality + ?? resultItem.placemark.administrativeArea + ?? "" + ) + .font(appearance.caption) + .foregroundColor(appearance.secondary) + } + } + .padding(.horizontal, 12) + + Divider() + } + } + .padding(8) + } + .frame(height: 40) + .padding(.top, 8) + } + + // Dimiss button & Search bar + HStack { + Button(action: dismiss) { + Image.close.medium + } + .tint(appearance.tint) + .frame(width: 36, height: 36) + + TextField("Search location...", text: $dataModel.searchText) + .frame(height: 36) + .padding(.horizontal, 18) + .background { + appearance.secondaryBackground + .clipShape(Capsule()) + } + } + .padding(.top, dataModel.searchedResults.isEmpty ? 16 : 8) + .padding(.horizontal, 16) + .padding(.bottom, 16) + } + .background { appearance.background } + case .notAllowed, .tracking: + VStack(spacing: 8) { + ZStack(alignment: .bottom) { + ZStack { + Map(coordinateRegion: .constant(dataModel.fixedRegion)) + .disabled(true) + .mask(LinearGradient(gradient: fade, startPoint: .top, endPoint: .bottom)) + + Image.location.medium + .foregroundColor(appearance.prominent) + .padding() + .background { + Circle() + .foregroundColor(appearance.tint) + } + .padding() + .background { + Circle() + .foregroundColor(appearance.tint.opacity(0.2)) + } + .padding() + .background { + Circle() + .foregroundColor(appearance.tint.opacity(0.1)) + } + } + + Text( + dataModel.locationTrackingStatus == .notAllowed + ? "Enable your location" + : "Send your location" + ) + .font(appearance.title) + .foregroundColor(appearance.primary) + } + .frame(height: 200) + + if dataModel.locationTrackingStatus == .notAllowed { + Text("This app requires that location services are\nturned on your device and for this app.\nYou must enable them in Settings before using this app") + .multilineTextAlignment(.center) + .font(appearance.subtitle) + .foregroundColor(appearance.secondary) + .padding(.bottom, 12) + + LocationButton(action: dataModel.requestLocation) + .foregroundColor(appearance.prominent) + .tint(appearance.tint) + .clipShape(Capsule()) + + Button(action: dismiss) { + Text("Cancel") + .font(appearance.footnote) + } + .tint(appearance.tint) + .padding(16) + } else { + Text("Loading ...") + .font(appearance.footnote) + .foregroundColor(appearance.secondary) + .padding(.bottom, 12) + } + } + .background { appearance.background } + } + } + + func send() { + let coordinate = dataModel.coordinateRegion.center + + let _ = Empty() + .sink( + receiveCompletion: { _ in + let style = MessageStyle.media( + .location( + coordinate.latitude, + coordinate.longitude + ) + ) + sendMessagePublisher.send(style) + }, + receiveValue: { _ in } + ) + isPresented = false + } + + func dismiss() { + withAnimation { + isPresented = false + } + } + + public init(isPresented: Binding) { + self._isPresented = isPresented + } +} diff --git a/Sources/ChatUI/ChatInChannel/MessageField/Location/View/SendLocationButton.swift b/Sources/ChatUI/ChatInChannel/MessageField/Location/View/SendLocationButton.swift new file mode 100644 index 0000000..fd4e70e --- /dev/null +++ b/Sources/ChatUI/ChatInChannel/MessageField/Location/View/SendLocationButton.swift @@ -0,0 +1,39 @@ +// +// SendLocationButton.swift +// +// +// Created by Jaesung Lee on 2023/02/11. +// + +import SwiftUI + +struct SendLocationButton: View { + @Environment(\.appearance) var appearance + + let action: () -> Void + + var body: some View { + Button(action: action) { + VStack(spacing: 0) { + Circle() + .foregroundColor(appearance.tint) + .frame(width: 48, height: 48) + .overlay { + Image.send.medium + .foregroundColor(appearance.prominent) + } + + Image(systemName: "arrowtriangle.down.fill") + .font(appearance.footnote) + .cornerRadius(2) + .foregroundColor(appearance.tint) + .offset(x: 0, y: -5) + .padding(.bottom, 48 + 5 + 12) + } + } + } + + public init(action: @escaping () -> Void) { + self.action = action + } +} diff --git a/Sources/ChatUI/ChatInChannel/MessageField/MessageField.swift b/Sources/ChatUI/ChatInChannel/MessageField/MessageField.swift new file mode 100644 index 0000000..7cfeab0 --- /dev/null +++ b/Sources/ChatUI/ChatInChannel/MessageField/MessageField.swift @@ -0,0 +1,213 @@ +// +// MessageField.swift +// +// +// Created by Jaesung Lee on 2023/02/08. +// + +import Combine +import SwiftUI +import PhotosUI + +public struct MessageField: View { + @EnvironmentObject private var configuration: ChatConfiguration + + @Environment(\.appearance) var appearance + + + @FocusState private var isTextFieldFocused: Bool + @State private var text: String = "" + @State private var textFieldHeight: CGFloat = 20 + @State private var giphyKey: String? + @State private var mediaData: Data? + + // Media + @State private var selectedItem: PhotosPickerItem? = nil + + @Binding var isMenuItemPresented: Bool + + @State private var isGIFPickerPresented: Bool = false + @State private var isCameraFieldPresented: Bool = false + @State private var isVoiceFieldPresented: Bool = false + + let options: [MessageOption] + let showsSendButtonAlways: Bool + let onSend: (_ messageStyle: MessageStyle) -> () + + private var leftSideOptions: [MessageOption] { + options.filter { $0 != .giphy } + } + + public var body: some View { + ZStack(alignment: .bottom) { + HStack(alignment: .bottom) { + if isTextFieldFocused, leftSideOptions.count > 1 { + Button(action: onTapHiddenButton) { + Image.buttonHidden.medium + .tint(appearance.tint) + } + .frame(width: 36, height: 36) + } else { + if options.contains(.menu) { + // More Button + Button(action: onTapMore) { + Image.menu.medium + } + .tint(appearance.tint) + .frame(width: 36, height: 36) + } + + // Camera Button + if options.contains(.camera) { + Button(action: onTapCamera) { + Image.camera.medium + } + .tint(appearance.tint) + .disabled(isMenuItemPresented) + .frame(width: 36, height: 36) + } + + // Photo Library Button + if options.contains(.photoLibrary) { + PhotosPicker( + selection: $selectedItem, + matching: .images, + photoLibrary: .shared() + ) { + Image.photoLibrary.medium + } + .tint(appearance.tint) + .disabled(isMenuItemPresented) + .frame(width: 36, height: 36) + .onChange(of: selectedItem) { newItem in + Task { + // Retrive selected asset in the form of Data + if let data = try? await newItem?.loadTransferable(type: Data.self) { + self.onSelectPhoto(data: data) + } + } + } + } + + // Mic Button + if options.contains(.mic) { + Button(action: onTapMic) { + Image.mic.medium + } + .tint(appearance.tint) + .disabled(isMenuItemPresented) + .frame(width: 36, height: 36) + } + } + + // TextField + HStack(alignment: .bottom) { + MessageTextField(text: $text, height: $textFieldHeight) + .frame(height: textFieldHeight < 90 ? textFieldHeight : 90) + .padding(.leading, 9) + .padding(.trailing, 4) + .focused($isTextFieldFocused) + + // Giphy Button + if options.contains(.giphy) { + Button(action: onTapGiphy) { + Image.giphy.medium + } + .tint(appearance.tint) + .disabled(isMenuItemPresented) + } + } + .padding(6) + .background { + appearance.secondaryBackground + .clipShape(RoundedRectangle(cornerRadius: 18)) + } + + // Send Button + if showsSendButtonAlways || !text.isEmpty { + Button(action: onTapSend) { + Image.send.medium + } + .frame(width: 36, height: 36) + .tint(appearance.tint) + .disabled(text.isEmpty) + } + } + .padding(16) + + if isVoiceFieldPresented { + VoiceField(isPresented: $isVoiceFieldPresented) + } + + if isCameraFieldPresented { + CameraField(isPresented: $isCameraFieldPresented) + } + } + .sheet(isPresented: $isGIFPickerPresented) { + if let giphyKey = configuration.giphyKey { + GiphyPicker(giphyKey: giphyKey) + .ignoresSafeArea() + .presentationDetents([.fraction(0.9)]) + .presentationDragIndicator(.hidden) + } else { + Text("No Giphy Key") + } + } + .onReceive(sendMessagePublisher) { messageStyle in + onSend(messageStyle) + } + } + + + public init( + options: [MessageOption] = MessageOption.all, + showsSendButtonAlways: Bool = false, + isMenuItemPresented: Binding = .constant(false), + onSend: @escaping (_ messageStyle: MessageStyle) -> () + ) { + self.options = options + self.showsSendButtonAlways = showsSendButtonAlways + self._isMenuItemPresented = isMenuItemPresented + self.onSend = onSend + } + + func onTapHiddenButton() { + isTextFieldFocused = false + } + + func onTapMore() { + isMenuItemPresented.toggle() + } + + func onTapCamera() { + dismissMenuItems() + isCameraFieldPresented = true + } + + func onSelectPhoto(data: Data) { + onSend(.media(.photo(data))) + } + + func onTapMic() { + dismissMenuItems() + isVoiceFieldPresented = true + } + + /// Shows ``GiphyPicker`` + func onTapGiphy() { + dismissMenuItems() + isGIFPickerPresented = true + } + + func onTapSend() { + guard !text.isEmpty else { return } + onSend(.text(text)) + text = "" + } + + func dismissMenuItems() { + isMenuItemPresented = false + isCameraFieldPresented = false + isVoiceFieldPresented = false + } +} diff --git a/Sources/ChatUI/ChatInChannel/MessageField/MessageTextField.swift b/Sources/ChatUI/ChatInChannel/MessageField/MessageTextField.swift new file mode 100644 index 0000000..25971f2 --- /dev/null +++ b/Sources/ChatUI/ChatInChannel/MessageField/MessageTextField.swift @@ -0,0 +1,82 @@ +// +// MessageTextField.swift +// +// +// Created by Jaesung Lee on 2023/02/09. +// + +import UIKit +import SwiftUI + +struct MessageTextField: UIViewRepresentable { + @Binding var text: String + @Binding var height: CGFloat + + @State private var isEditing: Bool = false + let placeholder: String = String.MessageField.placeholder + + func makeUIView(context: UIViewRepresentableContext) -> UITextView { + let view = UITextView() + view.backgroundColor = .clear + view.font = UIFont.preferredFont(forTextStyle: .subheadline) + view.text = placeholder + view.textColor = UIColor.tertiaryLabel + view.delegate = context.coordinator + view.isEditable = true + view.isUserInteractionEnabled = true + view.isScrollEnabled = true + return view + } + + func updateUIView(_ uiView: UITextView, context: UIViewRepresentableContext) { + if text.isEmpty { + uiView.text = self.isEditing ? "" : placeholder + uiView.textColor = self.isEditing ? UIColor.label : UIColor.tertiaryLabel + } else { + uiView.textColor = UIColor.label + } + + DispatchQueue.main.async { + self.height = uiView.contentSize.height + uiView.textContainerInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) + } + } + + func makeCoordinator() -> Coordinator { + MessageTextField.Coordinator(parent: self) + } + + class Coordinator: NSObject, UITextViewDelegate { + var parent: MessageTextField + + init(parent: MessageTextField) { + self.parent = parent + } + + func textViewDidBeginEditing(_ textView: UITextView) { + DispatchQueue.main.async { + textView.text = self.parent.text + self.parent.isEditing = true + } + } + + func textViewDidEndEditing(_ textView: UITextView) { + DispatchQueue.main.async { + self.parent.isEditing = false + } + } + + func textViewDidChange(_ textView: UITextView) { + if textView.text.count > 300 { + let start = textView.text.index(textView.text.startIndex, offsetBy: 0) + let end = textView.text.index(textView.text.startIndex, offsetBy: 300) + textView.text = String(textView.text[start..() + .sink( + receiveCompletion: { _ in + let style = MessageStyle + .voice(data) + sendMessagePublisher.send(style) + }, + receiveValue: { _ in } + ) + isPresented = false + } + } + } + + func cancel() { + dataModel.cancelRecording() + isPresented = false + } +} diff --git a/Sources/ChatUI/ChatInChannel/MessageList.swift b/Sources/ChatUI/ChatInChannel/MessageList.swift new file mode 100644 index 0000000..a3e457f --- /dev/null +++ b/Sources/ChatUI/ChatInChannel/MessageList.swift @@ -0,0 +1,184 @@ +// +// MessageList.swift +// +// +// Created by Jaesung Lee on 2023/02/08. +// + +import SwiftUI + + +public struct MessageList: View { + + @EnvironmentObject var configuration: ChatConfiguration + + @Environment(\.appearance) var appearance + + @State private var isKeyboardShown = false + @State private var scrollOffset: CGFloat = 0 + @State private var showsScrollButton: Bool = false + + /// The latest message goes very first. + let showsDate: Bool + let rowContent: (_ message: MessageType) -> RowContent + let listName = "name.list.message" + + let sendingMessages: [MessageType] + let failedMessages: [MessageType] + let sentMessages: [MessageType] + let deliveredMessages: [MessageType] + let seenMessages: [MessageType] + + public var body: some View { + ZStack(alignment: .bottom) { + ScrollViewReader { scrollView in + ScrollView { + GeometryReader { proxy in + let frame = proxy.frame(in: .named(listName)) + let offset = frame.minY + Color.clear + .preference( + key: ScrollViewOffsetPreferenceKey.self, + value: offset + ) + } + + LazyVStack(spacing: 0) { + ForEach(sendingMessages) { message in + rowContent(message) + .padding(.horizontal, 12) + .effect(.flipped) + } + + ForEach(failedMessages) { message in + rowContent(message) + .padding(.horizontal, 12) + .effect(.flipped) + } + + ForEach(sentMessages) { message in + rowContent(message) + .padding(.horizontal, 12) + .effect(.flipped) + } + + ForEach(deliveredMessages) { message in + rowContent(message) + .padding(.horizontal, 12) + .effect(.flipped) + } + + ForEach(seenMessages) { message in + VStack(spacing: 0) { + rowContent(message) + + if message.id == seenMessages.first?.id, message.sender.id == configuration.userID { + HStack { + Spacer() + + Text("seen") + .font(appearance.footnote) + .foregroundColor(appearance.secondary) + } + .padding(.trailing, 21) + } + } + .padding(.horizontal, 12) + .effect(.flipped) + } + } + } + .coordinateSpace(name: listName) + .onPreferenceChange(ScrollViewOffsetPreferenceKey.self) { value in + DispatchQueue.main.async { [self] in + let offsetValue = value ?? 0 + let diff = offsetValue - scrollOffset + scrollOffset = offsetValue + + let isScrollButtonShown = offsetValue < -20 + if showsScrollButton != isScrollButtonShown { + showsScrollButton = isScrollButtonShown + } + + if isKeyboardShown && diff < -20 { + isKeyboardShown = false + + UIApplication.shared + .sendAction( + #selector(UIResponder.resignFirstResponder), + to: nil, + from: nil, + for: nil + ) + } + } + } + .effect(.flipped) + /// When tapped sending button. Called after `onSent` in ``MessageField`` + .onChange(of: sendingMessages) { newValue in + if let id = sendingMessages.first?.id { + print("☺️ \(id)") + withAnimation { + scrollView.scrollTo(id, anchor: .bottom) + } + } + } + /// When tapped ``ScrollButton`` + .onReceive(scrollDownPublisher) { _ in + // Get the latest message ID + let id: String? = sendingMessages.first?.id + ?? failedMessages.first?.id + ?? sentMessages.first?.id + ?? deliveredMessages.first?.id + ?? seenMessages.first?.id + if let id = id { + withAnimation { + scrollView.scrollTo(id, anchor: .bottom) + } + } + } + } + + if showsScrollButton { + ScrollButton() + .padding(.bottom, 8) + } + } + } + + public init( + _ messageData: [MessageType], + showsDate: Bool = false, + @ViewBuilder rowContent: @escaping (_ message: MessageType) -> RowContent + ) { + var sendingMessages: [MessageType] = [] + var failedMessages: [MessageType] = [] + var sentMessages: [MessageType] = [] + var deliveredMessages: [MessageType] = [] + var seenMessages: [MessageType] = [] + + messageData.forEach { + switch $0.readReceipt { + case .sending: + sendingMessages.append($0) + case .failed: + failedMessages.append($0) + case .sent: + sentMessages.append($0) + case .delivered: + deliveredMessages.append($0) + case .seen, .played: + seenMessages.append($0) + } + } + + self.sendingMessages = sendingMessages + self.failedMessages = failedMessages + self.sentMessages = sentMessages + self.deliveredMessages = deliveredMessages + self.seenMessages = seenMessages + + self.showsDate = showsDate + self.rowContent = rowContent + } +} diff --git a/Sources/ChatUI/ChatInChannel/MessageRow.swift b/Sources/ChatUI/ChatInChannel/MessageRow.swift new file mode 100644 index 0000000..680b35a --- /dev/null +++ b/Sources/ChatUI/ChatInChannel/MessageRow.swift @@ -0,0 +1,168 @@ +// +// MessageRow.swift +// +// +// Created by Jaesung Lee on 2023/02/08. +// + +import SwiftUI + +public struct MessageRow: View { + + @EnvironmentObject var configuration: ChatConfiguration + @Environment(\.appearance) var appearance + + let message: M + let showsUsername: Bool + let showsDate: Bool + let showsProfileImage: Bool + let showsReadReceiptStatus: Bool + + var isMyMessage: Bool { + message.sender.id == configuration.userID + } + + public var body: some View { + VStack(spacing: 0) { + HStack(alignment: .bottom, spacing: 4) { + if isMyMessage { + Spacer() + + if showsDate { + Text( + Date(timeIntervalSince1970: message.sentAt), + formatter: formatter + ) + .messageStyle(.date) + } + } else { + if showsProfileImage { + AsyncImage(url: message.sender.imageURL) { image in + image + .resizable() + .messageStyle(.senderProfile) + } placeholder: { + Image(systemName: "person.crop.circle.fill") + .resizable() + .messageStyle(.senderProfile) + .foregroundColor(appearance.secondary) + } + } + } + + VStack(alignment: isMyMessage ? .trailing : .leading, spacing: 2) { + if showsUsername, !isMyMessage { + Text(message.sender.username) + .messageStyle(.senderName) + .padding(.horizontal, 8) + } + + switch message.style { + case .text(let text): + let markdown = LocalizedStringKey(text) + Text(markdown) + .tint(isMyMessage ? appearance.prominentLink : appearance.link) + .messageStyle(isMyMessage ? .localBody : .remoteBody) + case .media(let mediaType): + switch mediaType { + case .emoji(let key): + Text(key) + .messageStyle(isMyMessage ? .localBody : .remoteBody) + case .gif(let key): + GiphyStyleView(id: key) + case .photo(let data): + PhotoStyleView(data: data) + case .video(let data): + Text("\(data)") + .lineLimit(5) + .messageStyle(isMyMessage ? .localBody : .remoteBody) + case .document(let data): + Text("\(data)") + .lineLimit(5) + .messageStyle(isMyMessage ? .localBody : .remoteBody) + case .contact(let contact): + let markdown = """ + Name: **\(contact.givenName) \(contact.familyName)** + Phone: \(contact.phoneNumbers) + """ + Text(.init(markdown)) + .messageStyle(isMyMessage ? .localBody : .remoteBody) + case .location(let latitude, let longitude): + LocationStyleView( + latitude: latitude, + longitude: longitude + ) + } + case .voice(let data): + VoiceStyleView(data: data) + .messageStyle(isMyMessage ? .localBody : .remoteBody) + } + } + + if !isMyMessage { + if showsDate { + Text( + Date(timeIntervalSince1970: message.sentAt), + formatter: formatter + ) + .messageStyle(.date) + } + + Spacer() + } + } + + if showsReadReceiptStatus, isMyMessage { + HStack { + Spacer() + + switch message.readReceipt { + case .sending: + Image.sending.xSmall2 + .clipShape(Circle()) + .foregroundColor(appearance.secondary) + .padding(.top, 4) + case .failed: + Image.failed.xSmall2 + .clipShape(Circle()) + .foregroundColor(appearance.error) + .padding(.top, 4) + case .sent: + Image.sent.xSmall2 + .clipShape(Circle()) + .foregroundColor(appearance.tint) + .padding(.top, 4) + case .delivered: + Image.delivered.xSmall2 + .clipShape(Circle()) + .foregroundColor(appearance.tint) + .padding(.top, 4) + default: + EmptyView() + } + } + + } + } + } + + public init( + message: M, + showsUsername: Bool = true, + showsDate: Bool = true, + showsProfileImage: Bool = true, + showsReadReceiptStatus: Bool = true + ) { + self.message = message + self.showsUsername = showsUsername + self.showsDate = showsDate + self.showsProfileImage = showsProfileImage + self.showsReadReceiptStatus = showsReadReceiptStatus + } + + var formatter: DateFormatter { + let formatter = DateFormatter() + formatter.dateFormat = "hh:mm" + return formatter + } +} diff --git a/Sources/ChatUI/ChatInChannel/MessageSearchBar.swift b/Sources/ChatUI/ChatInChannel/MessageSearchBar.swift new file mode 100644 index 0000000..533c8fa --- /dev/null +++ b/Sources/ChatUI/ChatInChannel/MessageSearchBar.swift @@ -0,0 +1,40 @@ +// +// MessageSearchBar.swift +// +// +// Created by Jaesung Lee on 2023/02/15. +// + +import SwiftUI + +// TODO: Not current scope +struct MessageSearchBar: View { + @State private var searchText: String = "" + + var body: some View { + HStack { + Image(systemName: "magnifyingglass") + .xSmall + .foregroundColor(.secondary) + + TextField("Search messages...", text: $searchText) + .textFieldStyle(.plain) + } + .padding(.horizontal, 8) + .frame(height: 36) + .background { + Color(.secondarySystemBackground) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + } +} + + +/** + MessageList {... } + .searchable(.visible) + + // search icon appears on the toolbar (navigation trailing) + // When tap the icon -> search bar appears + // shows all messages include search text + */ diff --git a/Sources/ChatUI/ChatInChannel/MessageViews/GiphyStyleView.swift b/Sources/ChatUI/ChatInChannel/MessageViews/GiphyStyleView.swift new file mode 100644 index 0000000..ce318de --- /dev/null +++ b/Sources/ChatUI/ChatInChannel/MessageViews/GiphyStyleView.swift @@ -0,0 +1,24 @@ +// +// GiphyStyleView.swift +// +// +// Created by Jaesung Lee on 2023/02/09. +// + +import SwiftUI +import GiphyUISDK + +public struct GiphyStyleView: View { + @State private var aspectRatio: CGFloat = 1 + let id: String + + public var body: some View { + GiphyMediaView(gifID: id, aspectRatio: $aspectRatio) + .frame(width: 120 * aspectRatio, height: 120) + .clipShape(RoundedRectangle(cornerRadius: 21)) + } + + init(id: String) { + self.id = id + } +} diff --git a/Sources/ChatUI/ChatInChannel/MessageViews/LocationStyleView.swift b/Sources/ChatUI/ChatInChannel/MessageViews/LocationStyleView.swift new file mode 100644 index 0000000..651f335 --- /dev/null +++ b/Sources/ChatUI/ChatInChannel/MessageViews/LocationStyleView.swift @@ -0,0 +1,40 @@ +// +// LocationStyleView.swift +// +// +// Created by Jaesung Lee on 2023/02/09. +// + +import SwiftUI +import MapKit + +public struct LocationStyleView: View { + let latitude: Double + let longitude: Double + + var region: MKCoordinateRegion { + MKCoordinateRegion( + center: .init(latitude: latitude, longitude: longitude), + span: .init(latitudeDelta: 0.005, longitudeDelta: 0.005) + ) + } + + public var body: some View { + Map( + coordinateRegion: .constant(region), + interactionModes: [], + annotationItems: [Location(coordinate: .init(latitude: latitude, longitude: longitude))] + ) { location in + MapMarker(coordinate: location.coordinate) + } + .frame(width: 220, height: 120) + .clipShape(RoundedRectangle(cornerRadius: 21)) + } +} + +extension LocationStyleView { + struct Location: Identifiable { + var id: String { "\(coordinate.latitude)-\(coordinate.longitude)"} + let coordinate: CLLocationCoordinate2D + } +} diff --git a/Sources/ChatUI/ChatInChannel/MessageViews/PhotoStyleView.swift b/Sources/ChatUI/ChatInChannel/MessageViews/PhotoStyleView.swift new file mode 100644 index 0000000..4874674 --- /dev/null +++ b/Sources/ChatUI/ChatInChannel/MessageViews/PhotoStyleView.swift @@ -0,0 +1,42 @@ +// +// PhotoStyleView.swift +// +// +// Created by Jaesung Lee on 2023/02/09. +// + +import SwiftUI + +public struct PhotoStyleView: View { + let data: Data + var uiImage: UIImage? { + UIImage(data: data) + } + + public var body: some View { + if let uiImage = uiImage { + Image(uiImage: uiImage) + .resizable() + .scaledToFill() + .frame(width: 220, height: 120) + .clipShape(RoundedRectangle(cornerRadius: 21)) + } else { + placeholder + } + } + + var placeholder: some View { + RoundedRectangle(cornerRadius: 21) + .fill(Color(uiColor: .secondarySystemBackground)) + .frame(width: 220, height: 120) + .overlay { + VStack { + Image.downloadFailed.xLarge + + Text(String.Message.failedPhoto) + .font(.footnote.bold()) + } + .foregroundColor(Color.secondary) + } + } +} diff --git a/Sources/ChatUI/ChatInChannel/MessageViews/VoiceStyleView.swift b/Sources/ChatUI/ChatInChannel/MessageViews/VoiceStyleView.swift new file mode 100644 index 0000000..776774e --- /dev/null +++ b/Sources/ChatUI/ChatInChannel/MessageViews/VoiceStyleView.swift @@ -0,0 +1,93 @@ +// +// VoiceStyleView.swift +// +// +// Created by Jaesung Lee on 2023/02/10. +// + +import AVKit +import SwiftUI +import Combine + +/// Data model to handle `AVAudioPlayerDelegate` +class VoicePlayer: NSObject, ObservableObject, AVAudioPlayerDelegate { + @Published var audioPlayer: AVAudioPlayer? + @Published var isPlaying: Bool = false + @Published var duration: Int = 0 + + var timerPublisher: AnyCancellable? + + func setup(with data: Data) { + audioPlayer = try? AVAudioPlayer(data: data) + self.duration = Int(audioPlayer?.duration ?? 0) + audioPlayer?.delegate = self + } + + func play() { + if audioPlayer?.prepareToPlay() == true { + timerPublisher = Timer.publish(every: 0.2, on: .main, in: .common).autoconnect() + .sink(receiveValue: { value in + if let audioPlayer = self.audioPlayer { + self.duration = Int(audioPlayer.currentTime) + } + }) + audioPlayer?.play() + isPlaying = true + } + } + + func stop() { + timerPublisher?.cancel() + timerPublisher = nil + audioPlayer?.stop() + isPlaying = false + self.duration = Int(audioPlayer?.duration ?? 0) + } + + func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { + isPlaying = !flag + } +} + +public struct VoiceStyleView: View { + @StateObject private var dataModel = VoicePlayer() + + let data: Data + + public var body: some View { + HStack { + Button(action: controlAudioPlayer) { + Group { + if dataModel.isPlaying { + Image.pause.medium + } else { + Image.play.medium + } + } + .foregroundColor(.white) + } + + Text( + String( + format: "%02i:%02i", + dataModel.duration / 60, + dataModel.duration % 60 + ) + ) + .font(.footnote) + .fontWeight(.semibold) + .foregroundColor(.white) + } + .onAppear { + dataModel.setup(with: data) + } + } + + func controlAudioPlayer() { + if dataModel.isPlaying { + dataModel.stop() + } else { + dataModel.play() + } + } +} diff --git a/Sources/ChatUI/ChatInChannel/ScrollButton.swift b/Sources/ChatUI/ChatInChannel/ScrollButton.swift new file mode 100644 index 0000000..f653821 --- /dev/null +++ b/Sources/ChatUI/ChatInChannel/ScrollButton.swift @@ -0,0 +1,37 @@ +// +// ScrollButton.swift +// +// +// Created by Jaesung Lee on 2023/02/09. +// + +import SwiftUI +import Combine + +public struct ScrollButton: View { + @Environment(\.appearance) var appearance + + public var body: some View { + Button(action: scrollToBotton) { + Image.directionDown.small + .foregroundColor(appearance.tint) + .padding(8) + .background { + Color(.systemBackground) + .clipShape(Circle()) + .shadow(radius: 6) + } + } + } + + func scrollToBotton() { + let _ = Empty() + .sink( + receiveCompletion: { _ in + print("☺️ scrollsToBottom send") + scrollDownPublisher.send(()) + }, + receiveValue: { _ in } + ) + } +} diff --git a/Sources/ChatUI/ChatUI.swift b/Sources/ChatUI/ChatUI.swift new file mode 100644 index 0000000..1757893 --- /dev/null +++ b/Sources/ChatUI/ChatUI.swift @@ -0,0 +1,6 @@ +public struct ChatUI { + public private(set) var text = "Hello, World!" + + public init() { + } +} diff --git a/Sources/ChatUI/Enums/MediaType.swift b/Sources/ChatUI/Enums/MediaType.swift new file mode 100644 index 0000000..fca670c --- /dev/null +++ b/Sources/ChatUI/Enums/MediaType.swift @@ -0,0 +1,27 @@ +// +// MediaType.swift +// +// +// Created by Jaesung Lee on 2023/02/08. +// + +import Foundation +import Contacts + +/// The types of the media. +public enum MediaType: Hashable { + /// Emoji. + case emoji(_ id: String) + /// GIF + case gif(_ id: String) + /// Taken photo with the *Camera* or chosen photo from the *Gallery*. + case photo(_ data: Data) + /// Taken video with the *Camera* or chosen video from the *Phone*. + case video(_ date: Data) + /// Document file from the *Phone* + case document(_ data: Data) + /// Contact's information saved in the phone's *address book*. + case contact(_ contact: CNContact) + /// The user location or a nearby place. + case location(_ latitude: Double, _ longitude: Double) +} diff --git a/Sources/ChatUI/Enums/MessageItemPlacement.swift b/Sources/ChatUI/Enums/MessageItemPlacement.swift new file mode 100644 index 0000000..99d644d --- /dev/null +++ b/Sources/ChatUI/Enums/MessageItemPlacement.swift @@ -0,0 +1,21 @@ +// +// MessageItemPlacement.swift +// +// +// Created by Jaesung Lee on 2023/02/08. +// + +import Foundation + +// TODO: Not Support yet +/// The enumeration for the cases that how to aligns the messages. +enum MessageItemPlacement: Int, Hashable { + /// Aligns all messages to the left side. + case left + /// Aligns all messages to the right side. + case right + /// Aligns messages to the both sides by following rules: + /// - If the message is sent by the remote user, aligns to the left side. + /// - If the message is sent by the local user, aligns to the right side. + case both +} diff --git a/Sources/ChatUI/Enums/MessageOption.swift b/Sources/ChatUI/Enums/MessageOption.swift new file mode 100644 index 0000000..a6a4be3 --- /dev/null +++ b/Sources/ChatUI/Enums/MessageOption.swift @@ -0,0 +1,19 @@ +// +// MessageOption.swift +// +// +// Created by Jaesung Lee on 2023/02/08. +// + +import Foundation + +/// The option for how to send message such as camera, mic and so on. +public enum MessageOption: Int, Hashable, CaseIterable { + public static var all: [MessageOption] { MessageOption.allCases } + case camera + case photoLibrary + case mic + case giphy + case location + case menu +} diff --git a/Sources/ChatUI/Enums/MessageStyle.swift b/Sources/ChatUI/Enums/MessageStyle.swift new file mode 100644 index 0000000..fdfdff3 --- /dev/null +++ b/Sources/ChatUI/Enums/MessageStyle.swift @@ -0,0 +1,21 @@ +// +// MessageStyle.swift +// +// +// Created by Jaesung Lee on 2023/02/08. +// + +import Foundation +import SwiftUI + +/// The Styles of the message. +/// +/// The user can send a several styles of the messages such as text only, photos, videos, files, locations or voice messages. This enumeration defines cases of the messages' styles +public enum MessageStyle: Hashable { + /// General text message + case text(_ text: String) + /// The media message such as photo, video, document, contact and so on. + case media(_ mediaType: MediaType) + /// The voice message which is recorded by microphone in the chat screen. + case voice(_ data: Data) +} diff --git a/Sources/ChatUI/Enums/ReadReceipt.swift b/Sources/ChatUI/Enums/ReadReceipt.swift new file mode 100644 index 0000000..0ad5977 --- /dev/null +++ b/Sources/ChatUI/Enums/ReadReceipt.swift @@ -0,0 +1,26 @@ +// +// ReadReceipt.swift +// +// +// Created by Jaesung Lee on 2023/02/08. +// + +import Foundation + +/// The enumeration that represents the read receipt status of the message. +public enum ReadReceipt: Int, Codable, Hashable { + /// The message is sending + case sending + /// The message was failed sending + case failed + /// The message was successfully sent. + case sent + /// The message has been delivered to your recipient's phone or linked devices, but the recipient hasn’t seen it. + case delivered + /// The recipient has read your message. + /// - The recipient has read your message or seen your picture, audio file, or video + /// - The recipient has seen your voice message, but hasn’t played it. + case seen + /// The recipient has played your voice message. + case played +} diff --git a/Sources/ChatUI/Enums/TypingIndicatorPlacement.swift b/Sources/ChatUI/Enums/TypingIndicatorPlacement.swift new file mode 100644 index 0000000..57ed6fb --- /dev/null +++ b/Sources/ChatUI/Enums/TypingIndicatorPlacement.swift @@ -0,0 +1,16 @@ +// +// TypingIndicatorPlacement.swift +// +// +// Created by Jaesung Lee on 2023/02/08. +// + +import Foundation + +// TODO: Not Supported yet +enum TypingIndicatorPlacement { + /// Places the typing indicator in the navigation bar. + case navigationBar + /// Places the typing indicator in the message field section. + case messageField +} diff --git a/Sources/ChatUI/Essentials/ChatConfiguration.swift b/Sources/ChatUI/Essentials/ChatConfiguration.swift new file mode 100644 index 0000000..eadf18c --- /dev/null +++ b/Sources/ChatUI/Essentials/ChatConfiguration.swift @@ -0,0 +1,55 @@ +// +// ChatConfiguration.swift +// +// +// Created by Jaesung Lee on 2023/02/09. +// + +import SwiftUI +import GiphyUISDK + +/** + An object representing the configuration settings for a chat. The settings include the *user ID* and an optional *Giphy API key*. + + Use this object to configure the chat UI before starting a chat session. + To create a ``ChatConfiguration`` object, call its initializer with the required user ID and an optional Giphy API key. + By default, the ``ChatConfiguration/giphyKey`` property is set to `nil`, which means that Giphy integration is disabled. If you provide a valid Giphy API key, the ``ChatConfiguration/giphyKey`` property is set to that value and Giphy integration is enabled. + After creating a ``ChatConfiguration`` object, pass it to the ``ChatUI`` views as an environment object. + + - Important: You must set the ``ChatConfiguration/userID`` property to a unique identifier for the user. This ID is used to identify the user in the chat session and must be unique across all users in the chat. + + **Example usage:** + + ```swift + @StateObject var config = ChatConfiguration(userID: "user123", giphyKey: "your_giphy_api_key") + + var body: some View { + ChatView() + .environmentObject(config) + } + ``` + - Note: This object is an `ObservableObject` and can be *observed for changes* in its properties. Any changes to the ``ChatConfiguration/userID`` or ``ChatConfiguration/giphyKey`` properties will automatically update any views that depend on this object. + */ +open class ChatConfiguration: ObservableObject { + /// User ID for chat + public let userID: String + /// Giphy API key + public let giphyKey: String? + + /** + Initializes a new ``ChatConfiguration`` object with the specified *user ID* and *Giphy API key* (optional). + + - Parameters: + - userID: A unique identifier for the user. + - giphyKey: An optional Giphy API key. If provided, enables Giphy integration. + */ + public init(userID: String, giphyKey: String? = nil) { + self.userID = userID + self.giphyKey = giphyKey + + // Giphy + if let giphyKey = giphyKey { + Giphy.configure(apiKey: giphyKey) + } + } +} diff --git a/Sources/ChatUI/PreferenceKeys/ScrollViewOffsetPreferenceKey.swift b/Sources/ChatUI/PreferenceKeys/ScrollViewOffsetPreferenceKey.swift new file mode 100644 index 0000000..b1aa8da --- /dev/null +++ b/Sources/ChatUI/PreferenceKeys/ScrollViewOffsetPreferenceKey.swift @@ -0,0 +1,16 @@ +// +// ScrollViewOffsetPreferenceKey.swift +// +// +// Created by Jaesung Lee on 2023/02/08. +// + +import SwiftUI + +struct ScrollViewOffsetPreferenceKey: PreferenceKey { + static var defaultValue: CGFloat? = nil + + static func reduce(value: inout CGFloat?, nextValue: () -> CGFloat?) { + value = value ?? nextValue() + } +} diff --git a/Sources/ChatUI/Previews/ChatInChannel/ChannelStack.Previews.swift b/Sources/ChatUI/Previews/ChatInChannel/ChannelStack.Previews.swift new file mode 100644 index 0000000..ed10d61 --- /dev/null +++ b/Sources/ChatUI/Previews/ChatInChannel/ChannelStack.Previews.swift @@ -0,0 +1,56 @@ +// +// ChannelStack.Previews.swift +// +// +// Created by Jaesung Lee on 2023/02/09. +// + +import SwiftUI + +struct ChannelStack_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + Preview() + .environmentObject( + ChatConfiguration( + userID: "andrew_parker", + giphyKey: "wj5tEh9nAwNHVF3ZFavQ0zoaIyt8HZto" + ) + ) + } + } + + struct Preview: View { + @State private var messages: [Message] = [ + Message.message5, + Message.message4, + Message.message2, + Message.message1, + ] + + var body: some View { + ChannelStack(GroupChannel.channel1) { + MessageList(messages) { message in + MessageRow( + message: message, + showsUsername: false + ) + .padding(.top, 12) + } + + MessageField(isMenuItemPresented: .constant(false)) { + messages.insert( + Message( + id: UUID().uuidString, + sender: User.user1, + sentAt: Date().timeIntervalSince1970, + readReceipt: .sending, + style: $0 + ), + at: 0 + ) + } + } + } + } +} diff --git a/Sources/ChatUI/Previews/ChatInChannel/MessageField.Previews.swift b/Sources/ChatUI/Previews/ChatInChannel/MessageField.Previews.swift new file mode 100644 index 0000000..5641338 --- /dev/null +++ b/Sources/ChatUI/Previews/ChatInChannel/MessageField.Previews.swift @@ -0,0 +1,157 @@ +// +// MessageField.Previews.swift +// +// +// Created by Jaesung Lee on 2023/02/08. +// + +import SwiftUI + +struct MessageField_Previews: PreviewProvider { + static var previews: some View { + Group { + Preview() + .previewDisplayName("Message Field") + + VStack { + MessageField(options: [.mic]) { _ in } + + MessageField(options: [.camera, .photoLibrary]) { _ in } + + MessageField(options: [.menu]) { _ in } + + MessageField(options: [.giphy]) { _ in } + + MessageField(options: MessageOption.all) { _ in } + + } + .previewDisplayName("Message Field Options") + + MessageField(showsSendButtonAlways: true) { _ in } + .previewDisplayName("Showing Send Button Always") + + MenuItemPreview() + .previewDisplayName("Menu items") + + LocationSelectorPreview() + .previewDisplayName("Location Selector") + +// VoiceField(isPresented: .constant(true)) +// .previewDisplayName("Voice Field") + } + .environmentObject( + ChatConfiguration( + userID: User.user1.id, + giphyKey: "wj5tEh9nAwNHVF3ZFavQ0zoaIyt8HZto" + ) + ) + } + + struct Preview: View { + @State private var pendingMessage: Message? + @State private var isMenuItemPresented: Bool = false + + var body: some View { + VStack { + if let pendingMessage = pendingMessage { + Text(pendingMessage.id) + + Text(String(describing: pendingMessage.style)) + } + + Spacer() + + MessageField(isMenuItemPresented: $isMenuItemPresented) { messageStyle in + pendingMessage = Message( + id: UUID().uuidString, + sender: User.user1, + sentAt: Date().timeIntervalSince1970, + readReceipt: .sending, + style: messageStyle + ) + } + + if isMenuItemPresented { + LocationSelector(isPresented: .constant(true)) + } + } + } + } + + struct MenuItemPreview: View { + @Environment(\.appearance) var appearance + + @State private var isMenuItemPresented: Bool = false + + var body: some View { + VStack { + Spacer() + + MessageField( + options: [.menu], + isMenuItemPresented: $isMenuItemPresented + ) { _ in } + + if isMenuItemPresented { + additionalContent + } + } + .alert( + Text("Menu"), + isPresented: $isMenuItemPresented + ) { + Button(role: .destructive) { + // Handle the deletion. + } label: { + Text("Camera") + } + } + } + + var additionalContent: some View { + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack { + Button(action: { }) { + Image.location.large + .foregroundColor(.white) + .padding(14) + .background { + Color(.cyan) + .clipShape(Circle()) + } + } + + Button(action: { }) { + Image.music.large + .foregroundColor(.white) + .padding(14) + .background { + Color(.systemPink) + .clipShape(Circle()) + } + } + } + } + .background { appearance.secondaryBackground } + .frame(height: 124) + .edgesIgnoringSafeArea(.bottom) + } + } + + struct LocationSelectorPreview: View { + var body: some View { + VStack { + Spacer() + + LocationSelector(isPresented: .constant(true)) + } + + } + } + + struct CameraViewPreview: View { + var body: some View { + CameraField(isPresented: .constant(true)) + } + } +} diff --git a/Sources/ChatUI/Previews/ChatInChannel/MessageList.Previews.swift b/Sources/ChatUI/Previews/ChatInChannel/MessageList.Previews.swift new file mode 100644 index 0000000..e5b3087 --- /dev/null +++ b/Sources/ChatUI/Previews/ChatInChannel/MessageList.Previews.swift @@ -0,0 +1,117 @@ +// +// MessageList.Previews.swift +// +// +// Created by Jaesung Lee on 2023/02/08. +// + +import SwiftUI + +struct MessageList_Previews: PreviewProvider { + static var previews: some View { + Group { + NavigationView { + Preview() + } + .previewDisplayName("With Message Field and Navigation") + + MessageList([Message.message1, Message.message2, Message.message3]) { message in + Menu { + Button { + // Add this item to a list of favorites. + } label: { + Label("Add to Favorites", systemImage: "heart") + } + Button { + // Open Maps and center it on this item. + } label: { + Label("Show in Maps", systemImage: "mappin") + } + } label: { + MessageRow(message: message) + } + } + .previewDisplayName("Message List") + } + .environmentObject( + ChatConfiguration( + userID: User.user1.id, + giphyKey: "wj5tEh9nAwNHVF3ZFavQ0zoaIyt8HZto" + ) + ) + } + + struct Preview: View { + @Environment(\.appearance) var appearance + + @State private var messages: [Message] = [ + Message.message1, Message.message2, Message.message3 + ] + + var body: some View { + VStack(spacing: 0) { + MessageList(messages) { message in + MessageRow(message: message) + .onTapGesture(count: 1) { + withAnimation { + switch message.readReceipt { + case .failed: + messages.removeAll { $0.id == message.id } + default: + messages.removeAll { $0.id == message.id } + } + } + } + } + + MessageField { + messages.insert( + Message( + id: UUID().uuidString, + sender: User.user1, + sentAt: Date().timeIntervalSince1970, + readReceipt: .sending, + style: $0 + ), + at: 0 + ) + } + + } + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + HStack { + AsyncImage(url: User.user1.imageURL) { image in + image + .resizable() + .frame(width: 36, height: 36) + .aspectRatio(contentMode: .fill) + .clipShape(Circle()) + .padding(1) + .background { + appearance.border + .clipShape(Circle()) + } + } placeholder: { + Image(systemName: "person.crop.circle.fill") + .resizable() + .frame(width: 36, height: 36) + .aspectRatio(contentMode: .fill) + .foregroundColor(.secondary) + .clipShape(Circle()) + } + + VStack(alignment: .leading) { + Text(User.user1.username) + .font(.headline) + + Text(User.user1.id) + .font(.footnote) + .foregroundColor(.secondary) + } + } + } + } + } + } +} diff --git a/Sources/ChatUI/Previews/ChatInChannel/MessageRow.Previews.swift b/Sources/ChatUI/Previews/ChatInChannel/MessageRow.Previews.swift new file mode 100644 index 0000000..14908fc --- /dev/null +++ b/Sources/ChatUI/Previews/ChatInChannel/MessageRow.Previews.swift @@ -0,0 +1,169 @@ +// +// MessageRow.Previews.swift +// +// +// Created by Jaesung Lee on 2023/02/08. +// + +import SwiftUI + +struct MessageRow_Previews: PreviewProvider { + static var previews: some View { + Group { + // MARK: - Message Rows + ScrollView(showsIndicators: false) { + LazyVStack { + /// **General** + MessageRow(message: Message.message3) + + /// Hide **username** + MessageRow( + message: Message.message3, + showsUsername: false + ) + + /// Hide **date** + MessageRow( + message: Message.message3, + showsUsername: false, + showsDate: false + ) + + /// Hide **profileImage** + MessageRow( + message: Message.message3, + showsUsername: false, + showsDate: false, + showsProfileImage: false + ) + + Divider() + + /// **General** + MessageRow( + message: Message.message7 + ) + + /// Hide **username** + MessageRow( + message: Message.message7, + showsUsername: false + ) + + /// Hide **date** + MessageRow( + message: Message.message7, + showsUsername: false, + showsDate: false + ) + + /// Hide **profileImage** + MessageRow( + message: Message.message7, + showsUsername: false, + showsDate: false, + showsProfileImage: false + ) + + /// Hide **read recipt** + MessageRow( + message: Message.message7, + showsUsername: false, + showsDate: false, + showsProfileImage: false, + showsReadReceiptStatus: false + ) + } + } + .previewDisplayName("Message Rows") + + // MARK: - Read Recipt + ScrollView(showsIndicators: false) { + LazyVStack { + /// **Sending Message** + MessageRow( + message: Message.message9, + showsUsername: false + ) + + /// **Failed Message** + MessageRow( + message: Message.message8, + showsUsername: false + ) + + /// **Sent Message** + MessageRow( + message: Message.message6, + showsUsername: false + ) + + /// **Delivered Message** + MessageRow( + message: Message.message7, + showsUsername: false + ) + + /// **Seen Message** + MessageRow( + message: Message.message2 + ) + } + } + .previewDisplayName("Read Recipt") + + // MARK: - Message Style + ScrollView(showsIndicators: false) { + LazyVStack { + /// **Text Message** + /// ``MessageStyle/text(_:)`` + MessageRow(message: Message.message2) + + /// **Location Message** + /// ``MessageStyle/media(_:)``, ``MediaType/location(_:_:)`` + MessageRow(message: Message.locationMessage) + + /// **Photo Message** + /// ``MessageStyle/media(_:)``, ``MediaType/photo(_:)`` + MessageRow(message: Message.photoMessage) + + /// **Failed Photo Message** + MessageRow(message: Message.photoFailedMessage) + + /// **GIF Message** + /// ``MessageStyle/media(_:)``, ``MediaType/gif(_:)`` + MessageRow(message: Message.giphyMessage) + } + } + .previewDisplayName("Message Style") + + MessageItemMenuPreview() + .previewDisplayName("Message Menu") + } + .environmentObject(ChatConfiguration( + userID: User.user1.id, + giphyKey: "wj5tEh9nAwNHVF3ZFavQ0zoaIyt8HZto" + )) + .padding(12) + } + + struct MessageItemMenuPreview: View { + @State private var isMessageMenuPresented: Bool = false + var body: some View { + Menu { + Button { + // Add this item to a list of favorites. + } label: { + Label("Add to Favorites", systemImage: "heart") + } + Button { + // Open Maps and center it on this item. + } label: { + Label("Show in Maps", systemImage: "mappin") + } + } label: { + MessageRow(message: Message.message2) + } + } + } +} diff --git a/Sources/ChatUI/Previews/ChatInChannel/MessageSearch.Previews.swift b/Sources/ChatUI/Previews/ChatInChannel/MessageSearch.Previews.swift new file mode 100644 index 0000000..6d31f25 --- /dev/null +++ b/Sources/ChatUI/Previews/ChatInChannel/MessageSearch.Previews.swift @@ -0,0 +1,15 @@ +// +// MessageSearch.Previews.swift +// +// +// Created by Jaesung Lee on 2023/02/15. +// + +import SwiftUI + +// TODO: Not current scope +struct MessageSearch_Previews: PreviewProvider { + static var previews: some View { + MessageSearchBar() + } +} diff --git a/Sources/ChatUI/Previews/TestModels/GroupChannel.swift b/Sources/ChatUI/Previews/TestModels/GroupChannel.swift new file mode 100644 index 0000000..4ed7c9f --- /dev/null +++ b/Sources/ChatUI/Previews/TestModels/GroupChannel.swift @@ -0,0 +1,37 @@ +// +// GroupChannel.swift +// +// +// Created by Jaesung Lee on 2023/02/08. +// + +import Foundation + +struct GroupChannel: ChannelProtocol { + var id: String + var name: String + var imageURL: URL? + var members: [User] + var createdAt: Double + var lastMessage: Message? +} + +extension GroupChannel { + static let channel1 = GroupChannel( + id: User.bluebottle.id, + name: User.bluebottle.username, + imageURL: User.bluebottle.imageURL, + members: [User.user1, User.bluebottle], + createdAt: 1675242048, + lastMessage: nil + ) + + static let new = GroupChannel( + id: UUID().uuidString, + name: User.user2.username, + imageURL: nil, + members: [User.user1, User.user2], + createdAt: 1675860481, + lastMessage: Message.message1 + ) +} diff --git a/Sources/ChatUI/Previews/TestModels/Message.swift b/Sources/ChatUI/Previews/TestModels/Message.swift new file mode 100644 index 0000000..1185e5c --- /dev/null +++ b/Sources/ChatUI/Previews/TestModels/Message.swift @@ -0,0 +1,134 @@ +// +// Message.swift +// +// +// Created by Jaesung Lee on 2023/02/08. +// + +import Foundation +import GiphyUISDK + +struct Message: MessageProtocol, Identifiable { + var id: String + var sender: User + var sentAt: Double + var editedAt: Double? + var readReceipt: ReadReceipt + var style: MessageStyle +} + +extension Message { + static let message1 = Message( + id: UUID().uuidString, + sender: User.bluebottle, + sentAt: 1675331868, + readReceipt: .seen, + style: .text("Hi, there! I would like to ask about my order [#1920543](https://instagram.com/j_sung_0o0). Your agent mentioned that it would be available on [September 18](mailto:). However, I haven’t been notified yet by your company about my product availability. Could you provide me some news regarding it?") + ) + + static let message2 = Message( + id: UUID().uuidString, + sender: User.user1, + sentAt: 1675342668, + readReceipt: .seen, + style: .text("Hi **Alexander**, we’re sorry to hear that. Could you give us some time to check on your order first? We will update you as soon as possible. Thanks!") + ) + + static let message3 = Message( + id: UUID().uuidString, + sender: User.starbucks, + sentAt: 1675342668, + readReceipt: .delivered, + style: .text("Hi **Daniel**,\nThanks for your booking. We’re pleased to have you on board with us soon. Please find your travel details attached.") + ) + + static let message4 = Message( + id: UUID().uuidString, + sender: User.bluebottle, + sentAt: 1675334868, + readReceipt: .seen, + style: .text("Do you know what time is it?") + ) + + static let message5 = Message( + id: UUID().uuidString, + sender: User.bluebottle, + sentAt: 1675338868, + readReceipt: .seen, + style: .text("What is the most popular meal in Japan?") + ) + + static let message6 = Message( + id: UUID().uuidString, + sender: User.user1, + sentAt: 1675404869, + readReceipt: .sent, + style: .text("Do you know what time is it?\nRead receipt status: `.sent`") + ) + + static let message7 = Message( + id: UUID().uuidString, + sender: User.user1, + sentAt: 1675408868, + readReceipt: .delivered, + style: .text("**What** is the most *popular* meal in [**Japan**](www.google.com)? ~~sushi~~\nRead receipt status: `.delivered`") + ) + + static let message8 = Message( + id: UUID().uuidString, + sender: User.user1, + sentAt: 1675408868, + readReceipt: .failed, + style: .text("Read receipt status: `.failed`") + ) + + static let message9 = Message( + id: UUID().uuidString, + sender: User.user1, + sentAt: 1675408868, + readReceipt: .sending, + style: .text("Read receipt status: `.sending`") + ) + + static let locationMessage = Message( + id: UUID().uuidString, + sender: User.user1, + sentAt: 1675408868, + readReceipt: .delivered, + style: .media(.location(37.57827, 126.97695)) + ) + + static let photoMessage: Message = { + let data = (try? Data(contentsOf: URL(string: "https://picsum.photos/220")!)) ?? Data() + return Message( + id: UUID().uuidString, + sender: User.user1, + sentAt: 1675408868, + readReceipt: .delivered, + style: .media(.photo(data)) + ) + }() + + static let photoFailedMessage: Message = { + let data = Data() + return Message( + id: UUID().uuidString, + sender: User.user1, + sentAt: 1675408868, + readReceipt: .failed, + style: .media(.photo(data)) + ) + }() + + static let giphyMessage: Message = { + let id = "SU49goxca2V5XzxJPf" + Giphy.configure(apiKey: "wj5tEh9nAwNHVF3ZFavQ0zoaIyt8HZto") + return Message( + id: UUID().uuidString, + sender: User.user1, + sentAt: 1675408868, + readReceipt: .delivered, + style: .media(.gif(id)) + ) + }() +} diff --git a/Sources/ChatUI/Previews/TestModels/User.swift b/Sources/ChatUI/Previews/TestModels/User.swift new file mode 100644 index 0000000..8689d1f --- /dev/null +++ b/Sources/ChatUI/Previews/TestModels/User.swift @@ -0,0 +1,46 @@ +// +// User.swift +// +// +// Created by Jaesung Lee on 2023/02/08. +// + +import Foundation + +struct User: UserProtocol { + var id: String + var username: String + var imageURL: URL? +} + +extension User { + static let user1 = User( + id: "andrew_parker", + username: "Andrew Parker", + imageURL: URL(string: "https://images.pexels.com/photos/614810/pexels-photo-614810.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1") + ) + + static let user2 = User( + id: "karen.castillo_96", + username: "Karen Castillo", + imageURL: URL(string: "https://images.unsplash.com/photo-1438761681033-6461ffad8d80?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxzZWFyY2h8Mnx8cGVyc29ufGVufDB8fDB8fA%3D%3D&w=1000&q=80") + ) + + static let noImage = User( + id: "lucas.ganimi", + username: "Lucas Ganimi" + ) + + static let starbucks = User( + id: "starbucks", + username: "Starbucks Coffee", + imageURL: URL(string: "https://pbs.twimg.com/profile_images/1268570190855331841/CiNnNX94_400x400.jpg") + ) + + static let bluebottle = User( + id: "bluebottle", + username: "Blue Bottle Coffee", + imageURL: URL(string: "https://pbs.twimg.com/profile_images/1514997622750138368/1mnEPbjo_400x400.jpg") + ) +} + diff --git a/Sources/ChatUI/Protocols/ChannelProtocol.swift b/Sources/ChatUI/Protocols/ChannelProtocol.swift new file mode 100644 index 0000000..c3a1e4d --- /dev/null +++ b/Sources/ChatUI/Protocols/ChannelProtocol.swift @@ -0,0 +1,23 @@ +// +// ChannelProtocol.swift +// +// +// Created by Jaesung Lee on 2023/02/08. +// + +import Foundation + +/// A protocol that defines the necessary information for displaying a channel regarding UI. +public protocol ChannelProtocol { + /// The associated type that conforms to ``UserProtocol`` + associatedtype UserType: UserProtocol + /// The associated type that conforms to ``MessageProtocol`` + associatedtype MessageType: MessageProtocol + + var id: String { get } + var name: String { get } + var imageURL: URL? { get } + var createdAt: Double { get } + var members: [UserType] { get } + var lastMessage: MessageType? { get } +} diff --git a/Sources/ChatUI/Protocols/KeyboardReadable.swift b/Sources/ChatUI/Protocols/KeyboardReadable.swift new file mode 100644 index 0000000..c4fed53 --- /dev/null +++ b/Sources/ChatUI/Protocols/KeyboardReadable.swift @@ -0,0 +1,47 @@ +// +// KeyboardReadable.swift +// +// +// Created by Jaesung Lee on 2023/02/08. +// + +import Combine +import SwiftUI + +/// The protoocl that defines the publisher to read the keyboard changes. +protocol KeyboardReadable { + /// The publisher that reads the changes on the keyboard. + var keyboardPublisher: AnyPublisher { get } +} + +extension KeyboardReadable { + var keyboardPublisher: AnyPublisher { + Publishers.Merge( + NotificationCenter.default + .publisher(for: UIResponder.keyboardWillShowNotification) + .map { _ in true }, + + NotificationCenter.default + .publisher(for: UIResponder.keyboardWillHideNotification) + .map { _ in false } + ) + .eraseToAnyPublisher() + } + + /// The publisher that reads the height of the keyboard. + public var keyboardHeight: AnyPublisher { + NotificationCenter.default + .publisher(for: UIResponder.keyboardDidShowNotification) + .map { + if let keyboardFrame: NSValue = $0 + .userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue { + let keyboardRectangle = keyboardFrame.cgRectValue + let keyboardHeight = keyboardRectangle.height + return keyboardHeight + } else { + return 0 + } + } + .eraseToAnyPublisher() + } +} diff --git a/Sources/ChatUI/Protocols/MessageProtocol.swift b/Sources/ChatUI/Protocols/MessageProtocol.swift new file mode 100644 index 0000000..1d97b3f --- /dev/null +++ b/Sources/ChatUI/Protocols/MessageProtocol.swift @@ -0,0 +1,21 @@ +// +// MessageProtocol.swift +// +// +// Created by Jaesung Lee on 2023/02/08. +// + +import Foundation + +/// A protocol that defines the necessary information for displaying a message regarding UI. +public protocol MessageProtocol: Hashable { + /// The associated type that conforms to ``UserProtocol`` + associatedtype UserType: UserProtocol + + var id: String { get } + var sender: UserType { get } + var sentAt: Double { get } + var editedAt: Double? { get } + var readReceipt: ReadReceipt { get } + var style: MessageStyle { get } +} diff --git a/Sources/ChatUI/Protocols/UserProtocol.swift b/Sources/ChatUI/Protocols/UserProtocol.swift new file mode 100644 index 0000000..0705890 --- /dev/null +++ b/Sources/ChatUI/Protocols/UserProtocol.swift @@ -0,0 +1,15 @@ +// +// UserProtocol.swift +// +// +// Created by Jaesung Lee on 2023/02/08. +// + +import Foundation + +/// A protocol that defines the necessary information for displaying a user regarding UI. +public protocol UserProtocol: Identifiable, Hashable { + var id: String { get } + var username: String { get } + var imageURL: URL? { get } +} diff --git a/Sources/ChatUI/Publishers/CapturedItemPublisher.swift b/Sources/ChatUI/Publishers/CapturedItemPublisher.swift new file mode 100644 index 0000000..4be4b68 --- /dev/null +++ b/Sources/ChatUI/Publishers/CapturedItemPublisher.swift @@ -0,0 +1,13 @@ +// +// CapturedItemPublisher.swift +// +// +// Created by Jaesung Lee on 2023/02/12. +// + +import Combine + +/** + The publisher that send events when the camera captured photo or video. The parameter provides a data of the captured item. + */ +public var capturedItemPublisher = PassthroughSubject() diff --git a/Sources/ChatUI/Publishers/LocationSearchResultPublisher.swift b/Sources/ChatUI/Publishers/LocationSearchResultPublisher.swift new file mode 100644 index 0000000..4fa4c3f --- /dev/null +++ b/Sources/ChatUI/Publishers/LocationSearchResultPublisher.swift @@ -0,0 +1,14 @@ +// +// LocationSearchResultPublisher.swift +// +// +// Created by Jaesung Lee on 2023/02/11. +// + +import Combine +import MapKit + +/** + The publisher that send events when the new `MKMapItem`objects are found as the search results. + */ +public var locationSearchResultPublisher = PassthroughSubject<[MKMapItem], Never>() diff --git a/Sources/ChatUI/Publishers/ScrollDownPublisher.swift b/Sources/ChatUI/Publishers/ScrollDownPublisher.swift new file mode 100644 index 0000000..806b178 --- /dev/null +++ b/Sources/ChatUI/Publishers/ScrollDownPublisher.swift @@ -0,0 +1,32 @@ +// +// ScrollDownPublisher.swift +// +// +// Created by Jaesung Lee on 2023/02/09. +// + +import Combine + +/** + The publisher that send events when the it needs to scroll to bottom. + + ```swift + // How to publish + let _ = Empty() + .sink( + receiveCompletion: { _ in + scrollDownPublisher.send(()) + }, + receiveValue: { _ in } + ) + ``` + ```swift + // How to subscribe + .onReceive(scrollDownPublisher) { _ in + withAnimation { + scrollView.scrollTo(id, anchor: .bottom) + } + } + ``` + */ +public var scrollDownPublisher = PassthroughSubject() diff --git a/Sources/ChatUI/Publishers/SendMessagePublisher.swift b/Sources/ChatUI/Publishers/SendMessagePublisher.swift new file mode 100644 index 0000000..679ace5 --- /dev/null +++ b/Sources/ChatUI/Publishers/SendMessagePublisher.swift @@ -0,0 +1,29 @@ +// +// SendMessagePublisher.swift +// +// +// Created by Jaesung Lee on 2023/02/11. +// + +import Combine + +/// The publisher that delivers ``MessageStyle`` +/// ```swift +/// // How to publish +/// let _ = Empty() +/// .sink( +/// receiveCompletion: { _ in +/// // Create `MessageStyle` object +/// let style = MessageStyle.text("{TEXT}") +/// sendMessagePublisher.send(style) +/// }, +/// receiveValue: { _ in } +/// ) +/// ``` +/// ```swift +/// // How to subscribe +/// .onReceive(sendMessagePublisher) { messageStyle in +/// // Handle `messageStyle` here (e.g., sending message with the style) +/// } +/// ``` +public var sendMessagePublisher = PassthroughSubject() diff --git a/Sources/ChatUI/ViewModifiers/KeyboardReaderModifier.swift b/Sources/ChatUI/ViewModifiers/KeyboardReaderModifier.swift new file mode 100644 index 0000000..6542464 --- /dev/null +++ b/Sources/ChatUI/ViewModifiers/KeyboardReaderModifier.swift @@ -0,0 +1,53 @@ +// +// KeyboardReaderModifier.swift +// +// +// Created by Jaesung Lee on 2023/02/08. +// + +import SwiftUI + +public class KeyboardReaderModifier { + public enum Interaction { + case hideKeyboardByTapping(_ shouldAdd: Bool) + } + + public struct HideKeyboardGesture: ViewModifier { + var shouldAdd: Bool + var onTapped: (() -> Void)? + + public init(shouldAdd: Bool, onTapped: (() -> Void)? = nil) { + self.shouldAdd = shouldAdd + self.onTapped = onTapped + } + + public func body(content: Content) -> some View { + content + .gesture( + shouldAdd + ? TapGesture().onEnded { _ in + UIApplication.shared + .sendAction( + #selector(UIResponder.resignFirstResponder), + to: nil, + from: nil, + for: nil + ) + + onTapped?() + } + : nil + ) + } + } + +} + +extension View { + public func keyboardReader(_ interaction: KeyboardReaderModifier.Interaction) -> some View { + switch interaction { + case .hideKeyboardByTapping(let shouldAdd): + return AnyView(modifier(KeyboardReaderModifier.HideKeyboardGesture(shouldAdd: shouldAdd))) + } + } +} diff --git a/Sources/ChatUI/ViewModifiers/MessageListModifier.swift b/Sources/ChatUI/ViewModifiers/MessageListModifier.swift new file mode 100644 index 0000000..22820a0 --- /dev/null +++ b/Sources/ChatUI/ViewModifiers/MessageListModifier.swift @@ -0,0 +1,45 @@ +// +// MessageListModifier.swift +// +// +// Created by Jaesung Lee on 2023/02/08. +// + +import SwiftUI + +public class EffectModifier { + public enum Style { + /// To Flip the view. It rotates the view and scales this view’s rendered output by horizontal amont -1. + case flipped + } + + /// The effect that flips the view. It rotates the view and scales this view’s rendered output by horizontal amont -1. + public struct FlippedEffect: ViewModifier { + public func body(content: Content) -> some View { + content + .rotationEffect(.radians(Double.pi)) + .scaleEffect(x: -1, y: 1, anchor: .center) + } + } +} + +extension View { + /** + Applies the effect such as *flipping* + + ``MessageList`` applies this effect as ``EffectModifier/Style/flipped`` and also the ``EffectModifier/Style/flipped`` is applied to the `rowContent` of the ``MessageList`` + + **Example usage:** + + ```swift + MessageRow(message: message) + .effect(.flipped) + ``` + */ + public func effect(_ style: EffectModifier.Style) -> some View { + switch style { + case .flipped: + return AnyView(modifier(EffectModifier.FlippedEffect())) + } + } +} diff --git a/Sources/ChatUI/ViewModifiers/MessageModifier.swift b/Sources/ChatUI/ViewModifiers/MessageModifier.swift new file mode 100644 index 0000000..83a31e9 --- /dev/null +++ b/Sources/ChatUI/ViewModifiers/MessageModifier.swift @@ -0,0 +1,126 @@ +// +// MessageModifier.swift +// +// +// Created by Jaesung Lee on 2023/02/08. +// + +import SwiftUI + +public class MessageModifier { + public enum Style { + case remoteBody + case localBody + case date + case receipt + case senderName + case senderProfile + } + + public static var remoteBodyStyle = RemoteBody() + + public static var localBodyStyle = LocalBody() + + public static var dateStyle = Date() + + public static var receiptStyle = Receipt() + + public static var senderName = SenderName() + + public static var senderProfile = SenderProfile() + + public struct RemoteBody: ViewModifier { + @Environment(\.appearance) var appearance + + public func body(content: Content) -> some View { + content + .lineLimit(10) + .font(appearance.messageBody) + .frame(minWidth: 18) /// To make the bubble to be a circle shape, when the text is too short + .padding(12) + .foregroundColor(appearance.primary) + .background(appearance.remoteMessageBackground) + .clipShape(RoundedRectangle(cornerRadius: 21)) + } + } + + public struct LocalBody: ViewModifier { + @Environment(\.appearance) var appearance + + public func body(content: Content) -> some View { + content + .lineLimit(10) + .font(appearance.messageBody) + .frame(minWidth: 18) /// To make the bubble to be a circle shape, when the text is too short + .padding(12) + .foregroundColor(appearance.background) + .background(appearance.localMessageBackground) + .clipShape(RoundedRectangle(cornerRadius: 21)) + } + } + + public struct Date: ViewModifier { + @Environment(\.appearance) var appearance + + public func body(content: Content) -> some View { + content + .font(appearance.caption) + .foregroundColor(appearance.secondary) + } + } + + public struct Receipt: ViewModifier { + + public func body(content: Content) -> some View { + content + .font(.largeTitle) + } + } + + public struct SenderName: ViewModifier { + @Environment(\.appearance) var appearance + + public func body(content: Content) -> some View { + content + .font(appearance.footnote) + .foregroundColor(appearance.secondary) + } + } + + public struct SenderProfile: ViewModifier { + @Environment(\.appearance) var appearance + + public func body(content: Content) -> some View { + content + .frame(width: 24, height: 24) + .aspectRatio(contentMode: .fill) + .clipShape(Circle()) + .padding(1) + .background { + appearance.imagePlaceholder + .clipShape(Circle()) + } + } + } +} + +extension View { + public func messageStyle(_ style: MessageModifier.Style) -> some View { + switch style { + case .remoteBody: + return AnyView(modifier(MessageModifier.remoteBodyStyle)) + case .localBody: + return AnyView(modifier(MessageModifier.localBodyStyle)) + case .date: + return AnyView(modifier(MessageModifier.dateStyle)) + case .receipt: + return AnyView(modifier(MessageModifier.receiptStyle)) + case .senderName: + return AnyView(modifier(MessageModifier.senderName)) + case .senderProfile: + return AnyView(modifier(MessageModifier.senderProfile)) + } + } +} + + diff --git a/Tests/ChatUITests/ChatUITests.swift b/Tests/ChatUITests/ChatUITests.swift new file mode 100644 index 0000000..f75f812 --- /dev/null +++ b/Tests/ChatUITests/ChatUITests.swift @@ -0,0 +1,11 @@ +import XCTest +@testable import ChatUI + +final class ChatUITests: XCTestCase { + func testExample() throws { + // This is an example of a functional test case. + // Use XCTAssert and related functions to verify your tests produce the correct + // results. + XCTAssertEqual(ChatUI().text, "Hello, World!") + } +}