├── .github ├── FUNDING.yml └── workflows │ └── greetings.yml ├── .gitignore ├── Documentation ├── Appearance │ ├── Appearance.md │ ├── Colors.md │ ├── ImageScales.md │ ├── Images.md │ └── Typography.md └── Chat_in_Channel │ ├── ChannelInfoView.md │ ├── MessageField.md │ ├── MessageList.md │ └── MessageRow.md ├── LICENSE ├── Package.resolved ├── Package.swift ├── README.md ├── Sources └── ChatUI │ ├── Appearance │ ├── Appearance.ChatUI.swift │ ├── CGSize.ChatUI.swift │ ├── ImageAppearance.ChatUI.swift │ ├── ImageAppearanceScheme.ChatUI.swift │ ├── ImageScale.ChatUI.swift │ └── String.ChatUI.swift │ ├── ChatInChannel │ ├── ChannelInfoView.swift │ ├── ChannelStack.swift │ ├── MessageDateView.swift │ ├── MessageField │ │ ├── Camera │ │ │ ├── DataModel │ │ │ │ ├── Camera.AVCapturePhotoCaptureDelegate.swift │ │ │ │ ├── Camera.AVCaptureVideoDataOutputSampleBufferDelegate.swift │ │ │ │ └── Camera.swift │ │ │ └── View │ │ │ │ ├── CameraField.swift │ │ │ │ ├── CameraView.swift │ │ │ │ └── CapturedItemView.swift │ │ ├── Giphy │ │ │ └── View │ │ │ │ ├── GiphyMediaView.swift │ │ │ │ └── GiphyPicker.swift │ │ ├── Location │ │ │ ├── DataModel │ │ │ │ └── LocationModel.swift │ │ │ └── View │ │ │ │ ├── LocationSelector.swift │ │ │ │ └── SendLocationButton.swift │ │ ├── MessageField.swift │ │ ├── MessageTextField.swift │ │ ├── Mic │ │ │ ├── DataModel │ │ │ │ └── Recorder.swift │ │ │ └── View │ │ │ │ └── VoiceField.swift │ │ └── NextMessageField.swift │ ├── MessageList.swift │ ├── MessageMenu │ │ └── MessageMenu.swift │ ├── MessageReaction │ │ ├── ReactionEffectView.swift │ │ └── ReactionSelector.swift │ ├── MessageRow.swift │ ├── MessageSearchBar.swift │ ├── MessageViews │ │ ├── GiphyStyleView.swift │ │ ├── LocationStyleView.swift │ │ ├── MessageView.swift │ │ ├── PhotoStyleView.swift │ │ └── VoiceStyleView.swift │ └── ScrollButton.swift │ ├── ChatUI.swift │ ├── Enums │ ├── MediaType.swift │ ├── MessageItemPlacement.swift │ ├── MessageOption.swift │ ├── MessageStyle.swift │ ├── ReadReceipt.swift │ └── TypingIndicatorPlacement.swift │ ├── Essentials │ ├── ChatConfiguration.swift │ └── GiphyConfiguration.swift │ ├── PreferenceKeys │ ├── BoundsPreferenceKey.swift │ └── ScrollViewOffsetPreferenceKey.swift │ ├── Previews │ ├── ChatInChannel │ │ ├── ChannelStack.Previews.swift │ │ ├── MessageField.Previews.swift │ │ ├── MessageList.Previews.swift │ │ ├── MessageRow.Previews.swift │ │ ├── MessageSearch.Previews.swift │ │ └── NextMessageField.Previews.swift │ └── TestModels │ │ ├── GroupChannel.swift │ │ ├── Message.swift │ │ └── User.swift │ ├── Protocols │ ├── ChannelProtocol.swift │ ├── KeyboardReadable.swift │ ├── MessageProtocol.swift │ └── UserProtocol.swift │ ├── Publishers │ ├── CapturedItemPublisher.swift │ ├── HighlightMessagePublisher.swift │ ├── KeyboardNotificationPublisher.swift │ ├── KeyboardVisibilityPublisher.swift │ ├── LocationSearchResultPublisher.swift │ ├── MessageReactionPublisher.swift │ ├── ScrollDownPublisher.swift │ ├── ScrolledToEndPublisher.swift │ └── SendMessagePublisher.swift │ └── ViewModifiers │ ├── KeyboardReaderModifier.swift │ ├── MessageListModifier.swift │ └── MessageModifier.swift └── Tests └── ChatUITests └── ChatUITests.swift /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [jaesung-0o0] 4 | -------------------------------------------------------------------------------- /.github/workflows/greetings.yml: -------------------------------------------------------------------------------- 1 | name: Greetings 2 | 3 | on: [pull_request_target, issues] 4 | 5 | jobs: 6 | greeting: 7 | runs-on: ubuntu-latest 8 | permissions: 9 | issues: write 10 | pull-requests: write 11 | steps: 12 | - uses: actions/first-interaction@v1 13 | with: 14 | repo-token: ${{ secrets.GITHUB_TOKEN }} 15 | issue-message: "🎉b Thanks for letting me know your first issue on ChatUI!" 16 | pr-message: "🎉 Thanks for your first pull request!" 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | 11 | 12 | Swift.gitignore 13 | 14 | build/ 15 | DerivedData/ 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | xcuserdata/ 25 | 26 | *.moved-aside 27 | *.xccheckout 28 | *.xcscmblueprint 29 | 30 | *.hmap 31 | *.ipa 32 | *.dSYM.zip 33 | *.dSYM 34 | 35 | # Swift Package Manager 36 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 37 | # Packages/ 38 | # Package.pins 39 | # Package.resolved 40 | # .build/ 41 | # Add this line if you want to avoid checking in Xcode SPM integration. 42 | .swiftpm/xcode 43 | 44 | 45 | *.xcodeproj/* 46 | !*.xcodeproj/project.pbxproj 47 | !*.xcodeproj/xcshareddata/ 48 | !*.xcworkspace/contents.xcworkspacedata 49 | /*.gcno 50 | 51 | **/xcshareddata/WorkspaceSettings.xcsettings -------------------------------------------------------------------------------- /Documentation/Appearance/Appearance.md: -------------------------------------------------------------------------------- 1 | # Appearance 2 | 3 | The `Appearance` struct represents a set of predefined appearances used in the app's user interface such as colors and typography. 4 | Use these colors to maintain consistency and familiarity in the user interface. 5 | 6 | ## Example Usage 7 | ```swift 8 | @Environment(\.appearance) var appearance 9 | 10 | var body: some View { 11 | Text("New Chat") 12 | .font(appearance.title) 13 | .foregroundColor(appearance.primary) 14 | } 15 | ``` 16 | 17 | ## Customization 18 | ```swift 19 | let appearance = Appearance(tint: .orange) 20 | 21 | var body: some View { 22 | ChatView() 23 | .environment(\.appearance, appearance) 24 | } 25 | ``` 26 | 27 | 28 | -------------------------------------------------------------------------------- /Documentation/Appearance/Colors.md: -------------------------------------------------------------------------------- 1 | # Colors 2 | 3 | | Property Name | Type | Description | Default Value | 4 | | --- | --- | --- | --- | 5 | | tint | Color | The main colors used in views provided by ChatUI. | Color(.systemBlue) | 6 | | primary | Color | The primary label color. | Color.primary | 7 | | secondary | Color | The secondary label color. | Color.secondary | 8 | | background | Color | The background color. | Color(.systemBackground) | 9 | | secondaryBackground | Color | The secondary background color. | Color(.secondarySystemBackground) | 10 | | localMessageBackground | Color | The background color for local user's message body. | Color(.tintColor) | 11 | | remoteMessageBackground | Color | The background color for remote user's message body. | Color(.secondarySystemBackground) | 12 | | imagePlaceholder | Color | The color used in image placeholder. | Color(.secondarySystemBackground) | 13 | | border | Color | The color used in border. | Color(.secondarySystemBackground) | 14 | | disabled | Color | The color used for disabled states. | Color.secondary | 15 | | error | Color | The color used for error states. | Color(.systemRed) | 16 | | prominent | Color | The prominent color. This color is used for text on prominent buttons. | Color.white | 17 | | link | Color | The link color. | Color(uiColor: .link) | 18 | | prominentLink | Color | The link color that is used in prominent views such as local message body. | Color(uiColor: .systemYellow) | 19 | -------------------------------------------------------------------------------- /Documentation/Appearance/ImageScales.md: -------------------------------------------------------------------------------- 1 | # Image Scales 2 | 3 | This Swift extension provides convenient properties to scale `Image` 4 | views to predefined sizes. The `scale(_:contentMode:)` 5 | method is used to resize an image or other view to a specific size while keeping its aspect ratio. 6 | 7 | ## Properties 8 | 9 | | Property Name | Size | Content Mode | 10 | | --- | --- | --- | 11 | | xSmall | 16 x 16 | .fit | 12 | | xSmall2 | 16 x 16 | .fill | 13 | | small | 20 x 20 | .fit | 14 | | small2 | 20 x 20 | .fill | 15 | | medium | 24 x 24 | .fit | 16 | | medium2 | 24 x 24 | .fill | 17 | | large | 36 x 36 | .fit | 18 | | large2 | 36 x 36 | .fill | 19 | | xLarge | 48 x 48 | .fit | 20 | | xLarge2 | 48 x 48 | .fill | 21 | | xxLarge | 64 x 64 | .fit | 22 | | xxLarge2 | 64 x 64 | .fill | 23 | | xxxLarge | 90 x 90 | .fit | 24 | | xxxLarge2 | 90 x 90 | .fill | 25 | 26 | ## Method 27 | 28 | ```swift 29 | func scale(_ scale: CGSize, contentMode: ContentMode) -> some View 30 | 31 | ``` 32 | 33 | **Description** 34 | 35 | Scales the view to the specified size while maintaining its aspect ratio. 36 | 37 | Use this method to resize an image or other view to a specific size while keeping its aspect ratio. 38 | 39 | **Parameters** 40 | 41 | | Parameter | Description | 42 | | --- | --- | 43 | | scale | The target size for the view, specified as a CGSize. | 44 | | contentMode | The content mode to use when scaling the view. The default value is ContentMode.aspectFit. | 45 | 46 | **Return Value** 47 | 48 | A new view that scales the original view to the specified size. 49 | 50 | **Example Usage** 51 | 52 | ```swift 53 | Image("my-image") 54 | .scale(CGSize(width: 100, height: 100), contentMode: .fill) 55 | ``` 56 | 57 | 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. 58 | 59 | -------------------------------------------------------------------------------- /Documentation/Appearance/Images.md: -------------------------------------------------------------------------------- 1 | # Images 2 | 3 | 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. 4 | 5 | | Property Name | Type | Default Value | Description | 6 | | --- | --- | --- | --- | 7 | | menu | Image | Image(systemName: "circle.grid.2x2.fill") | Icon for a menu | 8 | | camera | Image | Image(systemName: "camera.fill") | Icon for a camera | 9 | | photoLibrary | Image | Image(systemName: "photo") | Icon for a photo library | 10 | | mic | Image | Image(systemName: "mic.fill") | Icon for a microphone | 11 | | giphy | Image | Image(systemName: "face.smiling.fill") | Icon for GIPHY | 12 | | send | Image | Image(systemName: "paperplane.fill") | Icon for sending a message | 13 | | buttonHidden | Image | Image(systemName: "chevron.right") | Icon for a hidden button | 14 | | directionDown | Image | Image(systemName: "chevron.down") | Icon for a downward direction | 15 | | location | Image | Image(systemName: "location.fill") | Icon for a location | 16 | | document | Image | Image(systemName: "paperclip") | Icon for a document | 17 | | music | Image | Image(systemName: "music.note") | Icon for music | 18 | | sending | Image | Image(systemName: "circle.dotted") | Icon for a message that is currently being sent | 19 | | sent | Image | Image(systemName: "checkmark.circle") | Icon for a sent message | 20 | | delivered | Image | Image(systemName: "checkmark.circle.fill") | Icon for a delivered message | 21 | | failed | Image | Image(systemName: "exclamationmark.circle") | Icon for a failed message | 22 | | downloadFailed | Image | Image(systemName: "icloud.slash") | Icon for a failed download | 23 | | close | Image | Image(systemName: "xmark.circle.fill") | Icon for closing a window | 24 | | flip | Image | Image(systemName: "arrow.triangle.2.circlepath") | Icon for flipping an object | 25 | | delete | Image | Image(systemName: "trash") | Icon for deleting an object | 26 | | pause | Image | Image(systemName: "pause.circle.fill") | Icon for pausing an activity | 27 | | play | Image | Image(systemName: "play.circle.fill") | Icon for playing an activity | 28 | | person | Image | Image(systemName: "person.crop.circle.fill") | Icon for a person | 29 | 30 | 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. 31 | 32 | ```swift 33 | Image.send 34 | .resizable() 35 | .frame(width: 100, height: 100) 36 | .clipShape(Circle()) 37 | ``` 38 | -------------------------------------------------------------------------------- /Documentation/Appearance/Typography.md: -------------------------------------------------------------------------------- 1 | # Typography 2 | 3 | | Property Name | Type | Description | Default Value | 4 | | --- | --- | --- | --- | 5 | | body | Font | The font used in message's body. | .subheadline | 6 | | caption | Font | The font used in additional minor information such as date. | .caption | 7 | | footnote | Font | The font used in additional major information such as sender's name. | .footnote | 8 | | title | Font | The font used in the title such as the title of the channel in ChannelInfoView. | .headline | 9 | | subtitle | Font | The font used in the subtitle such as the subtitle of the channel in ChannelInfoView. | .footnote | 10 | -------------------------------------------------------------------------------- /Documentation/Chat_in_Channel/ChannelInfoView.md: -------------------------------------------------------------------------------- 1 | # ChannelInfoView 2 | 3 | This is a view that displays the following channel information: 4 | 5 | - The image of the channel 6 | - The title of the channel 7 | - The subtitle of the channel 8 | -------------------------------------------------------------------------------- /Documentation/Chat_in_Channel/MessageField.md: -------------------------------------------------------------------------------- 1 | # MessageField 2 | 3 | The message field is a UI component for sending messages. 4 | 5 | ## How to send a new message 6 | 7 | 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. 8 | 9 | ```swift 10 | MessageField { messageStyle in 11 | viewModel.sendMessage($0) 12 | } 13 | ``` 14 | 15 | ### Supported message styles 16 | 17 | - [x] text 18 | - [x] voice 19 | - [x] photo library 20 | - [x] giphy 21 | - [x] location 22 | - [ ] camera (*coming soon*) 23 | - [ ] document (*coming soon*) 24 | - [ ] contacts (*coming soon*) 25 | 26 | ## Handling menu items 27 | 28 | ```swift 29 | MessageField(isMenuItemPresented: $isMenuItemPresented) { ... } 30 | 31 | if isMenuItemPresented { 32 | MyMenuItemList() 33 | } 34 | ``` 35 | 36 | ## Sending location 37 | 38 | 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. 39 | 40 | > **NOTE:** 41 | > 42 | > 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) 43 | 44 | ```swift 45 | @State private var showsLocationSelector: Bool = false 46 | 47 | var body: some View { 48 | LocationSelector(isPresented: $showsLocationSelector) 49 | } 50 | ``` 51 | 52 | ## Sending a new message by using publisher 53 | 54 | ```swift 55 | public var sendMessagePublisher: PassthroughSubject 56 | ``` 57 | 58 | `sendMessagePublisher` is a Combine `Publisher` that passes `MessageStyle` object. 59 | 60 | ### How to publish 61 | 62 | To publish a new message, you can create a new `MessageStyle` object and send it using `send(_:)`. 63 | 64 | ```swift 65 | let _ = Empty() 66 | .sink( 67 | receiveCompletion: { _ in 68 | // Create `MessageStyle` object 69 | let style = MessageStyle.text("{TEXT}") 70 | // Publish the created style object via `send(_:)` 71 | sendMessagePublisher.send(style) 72 | }, 73 | receiveValue: { _ in } 74 | ) 75 | ``` 76 | 77 | ### How to subscribe 78 | 79 | You can subscribe to `sendMessagePublisher` to handle new messages. 80 | 81 | ```swift 82 | .onReceive(sendMessagePublisher) { messageStyle in 83 | // Handle `messageStyle` here (e.g., sending message with the style) 84 | } 85 | ``` 86 | 87 | ### Use cases 88 | - rating system 89 | - answering by defined message 90 | 91 | - - - 92 | -------------------------------------------------------------------------------- /Documentation/Chat_in_Channel/MessageList.md: -------------------------------------------------------------------------------- 1 | # MessageList 2 | 3 | ## Lists messages in row contents 4 | 5 | In the intializer, you can list message objects that conform to `MessageProtocol` to display messages using the `rowContent` parameter. 6 | 7 | All the body and row contents are flipped vertically so that new messages can be listed from the bottom. 8 | 9 | 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`. 10 | 11 | > **NOTE:** The order of the messages is like below: 12 | > 13 | > sending → failed → sent → delivered → seen 14 | 15 | ## Scrolls to bottom 16 | 17 | 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. 18 | 19 | ### How to publish 20 | 21 | ```swift 22 | let _ = Empty() 23 | .sink( 24 | receiveCompletion: { _ in 25 | scrollDownPublisher.send(()) 26 | }, 27 | receiveValue: { _ in } 28 | ) 29 | ``` 30 | 31 | ### How to subscribe 32 | 33 | ```swift 34 | .onReceive(scrollDownPublisher) { _ in 35 | withAnimation { 36 | scrollView.scrollTo(id, anchor: .bottom) 37 | } 38 | } 39 | ``` 40 | 41 | ## How to handle the keyboard visibilty 42 | 43 | ### Keyboard Visibility Publisher 44 | 45 | `keyboardVisibilityPublisher` sends a boolean value that uses to show/hide keyboard. 46 | 47 | `.keyboardVisibility(_:)` method: 48 | - sends the `Visibility` value via `keyboardVisibilityPublisher` just one time to change the keyboard visibility state. 49 | - receives `keyboardVisibilityPublisher` events, use `keyboardReader()` modifier. 50 | 51 | This examples shows a view that sends the `true` via `keyboardVisibility` publisher to shows keyboard and shows keyboard. 52 | 53 | ```swift 54 | SomeView() 55 | // Show up keyboard 56 | .keyboardVisibility(.visible) 57 | ``` 58 | When you want to receive event only, not send, assign `.automatic` to the parameter(`visibility`). 59 | This examples shows a view that only receives `keyboardVisibilityPublisher` events. 60 | 61 | ```swift 62 | SomeView() 63 | .keyboardVisibility(.automatic) 64 | ``` 65 | 66 | > **Recommendation:** 67 | > It's' recommended that use `onReceive(_:perform:)` together to receives `keyboardNotificationPublisher` event when use this method. 68 | 69 | ```swift 70 | @State var isKeyboardShown: Bool = false 71 | 72 | var body: some View { 73 | SomeView() 74 | .keyboardVisibility(isKeyboardShown ? .visible : .hidden) 75 | .onReceive(keyboardNotificationPublisher) { value in 76 | isKeyboardShown = value 77 | } 78 | } 79 | } 80 | ``` 81 | 82 | ### Keyboard Notification Publisher 83 | 84 | `keyboardVisibilityPublisher` sends the current status of keyboard visibility. 85 | 86 | When `UIResponder.keyboardWillShowNotification` notification is called, it sends `true`. 87 | When `UIResponder.keyboardWillHideNotification` notification is called, it sends `false`. 88 | 89 | This examples shows how to receive the publisher event and handle the delivered value. 90 | 91 | ```swift 92 | @State private isKeyboardShown: Bool = false 93 | 94 | var body: some View { 95 | SomeView(isKeyboardShown: $isKeyboardShown) 96 | .onReceive(keyboardNotificationPublisher) { isShown in 97 | isKeyboardShown = isShown 98 | } 99 | ``` 100 | 101 | ## How to show message menu on long press gesture 102 | 103 | You can add message menus to display when a `rowContent`(such as `MessageRow`) is on long press gesture by setting `menuContent` parameter of the `MessageList` initializer. 104 | 105 | `MessageMenu` and `MessageMenubuttonStyle` allow you to create message menu more easily. Here is an example: 106 | 107 | ```swift 108 | MessageList(messages) { message in 109 | // row content 110 | MessageRow(message: message) 111 | .padding(.top, 12) 112 | } menuContent: { highlightMessage in 113 | // menu content 114 | MessageMenu { 115 | // Copy action 116 | Button(action: copy) { 117 | HStack { 118 | Text("Copy") 119 | 120 | Spacer() 121 | 122 | Image(systemName: "doc.on.doc") 123 | } 124 | .padding(.horizontal, 16) 125 | .foregroundColor(appearance.primary) 126 | } 127 | .frame(height: 44) 128 | 129 | Divider() 130 | 131 | // Delete action 132 | Button(action: delete) { 133 | HStack { 134 | Text("Delete") 135 | 136 | Spacer() 137 | 138 | Image(systemName: "trash") 139 | } 140 | .padding(.horizontal, 16) 141 | .foregroundColor(appearance.primary) 142 | } 143 | .frame(height: 44) 144 | } 145 | .padding(.top, 12) 146 | } 147 | ``` 148 | 149 | -------------------------------------------------------------------------------- /Documentation/Chat_in_Channel/MessageRow.md: -------------------------------------------------------------------------------- 1 | # MessageRow 2 | 3 | ## Displays message content 4 | 5 | This is a view that is provided by default in ChatUI to display message information. 6 | 7 | It shows the following information: 8 | 9 | - Message content 10 | - Message sent date 11 | - Message sender information 12 | - Message delivery status 13 | 14 | ## Message Delivery Status 15 | 16 | The message delivery status can be one of the following: 17 | 18 | - sending 19 | - failed 20 | - sent 21 | - delivered 22 | - seen 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Jaesung 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "giphy-ios-sdk", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/Giphy/giphy-ios-sdk", 7 | "state" : { 8 | "revision" : "699483a3a2b534e9dc3a3ab85a9f7095e306bde1", 9 | "version" : "2.2.2" 10 | } 11 | }, 12 | { 13 | "identity" : "libwebp-xcode", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/SDWebImage/libwebp-Xcode", 16 | "state" : { 17 | "revision" : "4f52fc9b29600a03de6e05af16df0d694cb44301", 18 | "version" : "1.2.4" 19 | } 20 | } 21 | ], 22 | "version" : 2 23 | } 24 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.7 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "ChatUI", 8 | platforms: [.iOS(.v16)], 9 | products: [ 10 | // Products define the executables and libraries a package produces, and make them visible to other packages. 11 | .library( 12 | name: "ChatUI", 13 | targets: ["ChatUI"]), 14 | ], 15 | dependencies: [ 16 | // Dependencies declare other packages that this package depends on. 17 | // .package(url: /* package url */, from: "1.0.0"), 18 | .package(url: "https://github.com/Giphy/giphy-ios-sdk", .upToNextMajor(from: "2.1.3")) 19 | ], 20 | targets: [ 21 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 22 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 23 | .target( 24 | name: "ChatUI", 25 | dependencies: [ 26 | .product(name: "GiphyUISDK", package: "giphy-ios-sdk") 27 | ]), 28 | .testTarget( 29 | name: "ChatUITests", 30 | dependencies: ["ChatUI"]), 31 | ] 32 | ) 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Frame 1 (2)](https://user-images.githubusercontent.com/53814741/221372100-95787895-c183-40e8-ab54-ec2d9598de62.png) 2 | 3 | ChatUI is an open-source Swift package that provides a simple and reliable solution for implementing chat interfaces using SwiftUI. 4 | 5 | [![stability-beta](https://img.shields.io/badge/stability-beta-33bbff.svg?style=for-the-badge)](https://github.com/mkenney/software-guides/blob/master/STABILITY-BADGES.md#beta) 6 | 7 | [![ChatUI-examples](https://img.shields.io/badge/quickstart-chatui_examples-147EFB.svg?style=for-the-badge)](https://github.com/jaesung-0o0/ChatUI-examples) 8 | [![ChatUI-Figma-Community](https://img.shields.io/badge/design_system-chatui_figma-F24E1E.svg?style=for-the-badge)](https://www.figma.com/community/file/1211259538649728876) 9 | [![ChatUI-Canvas-Beta-Testing](https://img.shields.io/badge/canvas_app-beta-0D96F6.svg?style=for-the-badge)](https://testflight.apple.com/join/AKiViqEk) 10 | 11 | ## **Overview** 12 | 13 | *written by ChatGPT* 14 | 15 | 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. 16 | 17 | 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. 18 | 19 |

20 | 21 | 22 | 23 |

24 | 25 | 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. 26 | 27 | > **Note** 28 | > To see **Quickstart** or **Real use cases examples** projects, please go to [ChatUI-examples](https://github.com/jaesung-0o0/ChatUI-examples) 29 | 30 | > **Note** 31 | > To see **Figma**, the design resources, please see [ChatUI - Figma Community](https://www.figma.com/community/file/1211259538649728876) 32 | 33 | > **Note** 34 | > To see **ChatUI Canvas** app that allows to create view using ChatUI *without any code*, please see [Discussion - 🎉 ChatUI Canvas starts beta testing!](https://github.com/jaesung-0o0/ChatUI/discussions/5) 35 | 36 | ## **Contribution** 37 | 38 | 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. 39 | 40 | Please see [💪 How to Contribute](https://github.com/jaesung-0o0/ChatUI/discussions/1) in Discussion tab. 41 | 42 | > **Important** 43 | > 44 | > When you contribute code via pull request, please add the executable previews that conforms to `PreviewProvider`. 45 | 46 | ## **License** 47 | 48 | ChatUI is released under the MIT license. See **[LICENSE](https://github.com/jaesung-0o0/ChatUI/blob/main/LICENSE)** for details. 49 | 50 | ## **Installation** 51 | 52 | To use ChatUI in your project, follow these steps: 53 | 54 | 1. In Xcode, select **File** > **Swift Packages** > **Add Package Dependency**. 55 | 2. In the search bar, paste the ChatUI URL: **[https://github.com/jaesung-0o0/ChatUI](https://github.com/jaesung-0o0/ChatUI)** 56 | 3. Select the branch as **main** to install. 57 | 4. Click **Next**, and then click **Finish**. 58 | 59 | ## **Usage** 60 | 61 | To use ChatUI in your project, add the following import statement at the top of your file: 62 | 63 | ```swift 64 | import ChatUI 65 | ``` 66 | 67 | 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. 68 | 69 | ### Real Use-cases Examples 70 | 71 | To see **Quickstart** or **Real use cases examples** projects, please go to [ChatUI-examples](https://github.com/jaesung-0o0/ChatUI-examples) 72 | 73 | ### Design Resources 74 | 75 | To see **Figma**, the design resources, please see [ChatUI - Figma Community](https://www.figma.com/community/file/1211259538649728876) 76 | 77 | ## Key Functions 78 | 79 | ### Chat in Channels 80 | 81 | | Name | Description | Documentation | 82 | | --- | --- | --- | 83 | | ℹ️ Channel Info View | This is a view that displays the following channel information | [See documentation](/Documentation/Chat_in_Channel/ChannelInfoView.md) | 84 | | 🥞 Message List | This is a view that lists message objects. | [See documentation](/Documentation/Chat_in_Channel/MessageList.md) | 85 | | 💬 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) | 86 | | ⌨️ Message Field | The message field is a UI component for sending messages | [See documentation](/Documentation/Chat_in_Channel/MessageField.md) | 87 | 88 | ### List Channels 89 | 90 | *Coming soon* 91 | 92 | ### Appearances 93 | 94 | | Name | Description | Documentation | 95 | | --- | --- | --- | 96 | | 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) | 97 | | Colors | The predefined colors used in the ChatUI. | [See documentation](/Documentation/Appearance/Colors.md) | 98 | | Typography | The predefined colors used in the ChatUI. | [See documentation](/Documentation/Appearance/Typography.md) | 99 | | Images | The predefined images used in the ChatUI as an extension of the `SwiftUI.Image`. | [See documentation](/Documentation/Appearance/Images.md) | 100 | | Image Scales | The predefined image scales used in the ChatUI. | [See documentation](/Documentation/Appearance/ImageScales.md) | 101 | 102 | ## To do list 103 | 104 | ### Features (🚀 1.0.0 Feature Plans) 105 | 106 | If you have any feature you want, please let me know via *Issue* or *Discussion* 107 | 108 | - [x] MessageList: Dimiss keyboard when tap outside 109 | - [x] MessageList: Date view 110 | - [ ] MessageList: Publisher for retrieving more message while scrolling 111 | - [x] MessageList: Message Menu 112 | - [x] MessageList: Message reaction publisher 113 | - [ ] MessageRow: placement (e.g., Both, leftOnly, rightOnly) 114 | - [ ] MessageField: CameraCapturer 115 | - [ ] Giphy: Resize body with GIF frame size 116 | - [ ] MapView: The view to shows map when the `.media(.location)` style message was tapped. 117 | - [ ] MediaView: The view to display / play media in message 118 | - [ ] MessageSearch: The feature that searches messages. 119 | 120 | ### Next Key functions 121 | 122 | - [ ] List Chanels: ChannelList, ChannelGrid, ChannelRow, ChannelColumn 123 | - [ ] Thread in Message: CommentList, CommentRow, etc,... (Add comment to the message) 124 | - [ ] Feed: FeedStack (Add message to the feed) 125 | - [ ] List Notifications: NotificationList, NotificationRow 126 | 127 | ### Documentations 128 | 129 | - [ ] Code Style Convention 130 | - [ ] Issues Convention 131 | - [ ] Branch Convention (name, commit) 132 | - [ ] Pull Requests Convention (how to PR, review process) 133 | -------------------------------------------------------------------------------- /Sources/ChatUI/Appearance/Appearance.ChatUI.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Color.ChatUI.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/02/08. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension EnvironmentValues { 11 | public var appearance: Appearance { 12 | get { self[AppearanceKey.self] } 13 | set { self[AppearanceKey.self] = newValue } 14 | } 15 | } 16 | 17 | private struct AppearanceKey: EnvironmentKey { 18 | static var defaultValue: Appearance = Appearance() 19 | } 20 | 21 | /** 22 | The set of predefined appearances used in the app's user interface such as colors and fonts. 23 | 24 | Use these colors to maintain consistency and familiarity in the user interface. 25 | For example, use 26 | - the ``Appearance/tint`` color for the main colors, 27 | - the ``Appearance/background`` color for the view's background, 28 | - the ``Appearance/prominent`` color for text on prominent buttons. 29 | 30 | **Example Usage** 31 | 32 | ```swift 33 | @Environment(\.appearance) var appearance 34 | 35 | var body: some View { 36 | Text("New Chat") 37 | .font(appearance.title) 38 | .foregroundColor(appearance.primary) 39 | } 40 | ``` 41 | */ 42 | public struct Appearance { 43 | 44 | // MARK: Predefined Colors 45 | /// The main colors used in views provided by ``ChatUI``. The default is `Color(.systemBlue)` 46 | public let tint: Color 47 | /// The primary label color. 48 | public let primary: Color 49 | /// The secondary label color. 50 | public let secondary: Color 51 | /// The background color. 52 | public let background: Color 53 | /// The secondary background color. 54 | public let secondaryBackground: Color 55 | /// The background color for local user's message body. 56 | public let localMessageBackground: Color 57 | /// The background color for remote user's message body. 58 | public let remoteMessageBackground: Color 59 | /// The color used in image placehoder. 60 | public let imagePlaceholder: Color 61 | /// The color used in border. The default is `secondaryBackground` 62 | public let border: Color 63 | /// The color used for disabled states. The default is `Color.secondary` 64 | public let disabled: Color 65 | /// The color used for error states. The default is `Color(.systemRed)` 66 | public let error: Color 67 | /// The prominent color. This color is used for text on prominent buttons. The default is `Color.white`. 68 | public let prominent: Color 69 | /// The link color. The default is `Color(uiColor: .link)`. 70 | public let link: Color 71 | /// The link color that is used in prominent views such as *local* message body. The default is `Color(uiColor: .systemYellow)`. 72 | public let prominentLink: Color 73 | 74 | /// The font used in message's body 75 | public let body: Font 76 | /// The font used in additional minor information such as date 77 | public let caption: Font 78 | /// The font used in additional major information such as sender's name. 79 | public let footnote: Font 80 | /// The font used in the title such as the title of the channel in ``ChannelInfoView`` 81 | public let title: Font 82 | /// The font used in the subtitle such as the subtitle of the channel in ``ChannelInfoView`` 83 | public let subtitle: Font 84 | /// Format of the time shown next to a message, default is 12 hour time hh:mm 85 | public let messageTimeFormat: String 86 | 87 | /// Images used throughout, these are default and can be overridden for both light and dark modes 88 | public let images: ImageAsset 89 | 90 | public init( 91 | tint: Color = Color(.tintColor), 92 | primary: Color = Color.primary, 93 | secondary: Color = Color.secondary, 94 | background: Color = Color(.systemBackground), 95 | secondaryBackground: Color = Color(.secondarySystemBackground), 96 | localMessageBackground: Color = Color(.tintColor), 97 | remoteMessageBackground: Color = Color(.secondarySystemBackground), 98 | imagePlaceholder: Color = Color(.secondarySystemBackground), 99 | border: Color = Color(.secondarySystemBackground), 100 | disabled: Color = Color.secondary, 101 | error: Color = Color(.systemRed), 102 | prominent: Color = Color.white, 103 | link: Color = Color(uiColor: .link), 104 | prominentLink: Color = Color(uiColor: .systemYellow), 105 | body: Font = .subheadline, 106 | caption: Font = .caption, 107 | footnote: Font = .footnote, 108 | title: Font = .headline, 109 | subtitle: Font = .footnote, 110 | lightImages: ChatSymbols = ChatSymbols(), 111 | darkImages: ChatSymbols = ChatSymbols(), 112 | messageTimeFormat: String = "hh:mm" 113 | ) { 114 | self.tint = tint 115 | self.primary = primary 116 | self.secondary = secondary 117 | self.background = background 118 | self.secondaryBackground = secondaryBackground 119 | self.localMessageBackground = localMessageBackground 120 | self.remoteMessageBackground = remoteMessageBackground 121 | self.imagePlaceholder = imagePlaceholder 122 | self.border = border 123 | self.disabled = disabled 124 | self.error = error 125 | self.prominent = prominent 126 | self.link = link 127 | self.prominentLink = prominentLink 128 | self.messageTimeFormat = messageTimeFormat 129 | 130 | // Font 131 | self.body = body 132 | self.caption = caption 133 | self.footnote = footnote 134 | self.title = title 135 | self.subtitle = subtitle 136 | 137 | // Image 138 | self.images = ImageAsset(lightSymbol: lightImages, darkSymbol: darkImages) 139 | } 140 | } 141 | 142 | -------------------------------------------------------------------------------- /Sources/ChatUI/Appearance/CGSize.ChatUI.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CGSize.ChatUI.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/02/19. 6 | // 7 | 8 | import Foundation 9 | 10 | extension CGSize { 11 | /// Sizes for images used in ``ChatUI``. 12 | /// Use these values to ensure that images are displayed correctly in the chat user interface. 13 | /// ```swift 14 | /// Image.send 15 | /// .scale(.Image.medium, contentMode: .fit) 16 | /// ``` 17 | public class Image { 18 | /// 16 x 16 19 | public static var xSmall = CGSize(width: 16, height: 16) 20 | /// 20 x 20 21 | public static var small = CGSize(width: 20, height: 20) 22 | /// 24 x 24 23 | public static var medium = CGSize(width: 24, height: 24) 24 | /// 36 x 36 25 | public static var large = CGSize(width: 36, height: 36) 26 | /// 44 x 44 27 | public static var xLarge = CGSize(width: 48, height: 48) 28 | /// 64 x 64 29 | public static var xxLarge = CGSize(width: 64, height: 64) 30 | /// 90 x 90 31 | public static var xxxLarge = CGSize(width: 90, height: 90) 32 | } 33 | } 34 | 35 | -------------------------------------------------------------------------------- /Sources/ChatUI/Appearance/ImageAppearance.ChatUI.swift: -------------------------------------------------------------------------------- 1 | /// Defines images used in ``ChatUI``. 2 | /// 3 | /// Use these image names to display icons, avatars, and other images in the chat user interface. 4 | /// 5 | /// Example usage: 6 | /// ``` 7 | /// ChatSymbols.send 8 | /// .resizable() 9 | /// .frame(width: 100, height: 100) 10 | /// .clipShape(Circle()) 11 | /// ``` 12 | 13 | import Foundation 14 | import SwiftUI 15 | 16 | public struct ChatSymbols { 17 | 18 | /// circle.grid.2x2.fill 19 | public let menu: Image 20 | 21 | /// camera.fill 22 | public let camera: Image 23 | 24 | /// photo 25 | public let photoLibrary: Image 26 | 27 | /// mic.fill 28 | public let mic: Image 29 | 30 | /// face.smiling.fill 31 | public let giphy: Image 32 | 33 | /// paperplane.fill 34 | public let send: Image 35 | 36 | /// chevron.right 37 | public let buttonHidden: Image 38 | 39 | /// chevron.down 40 | public let directionDown: Image 41 | 42 | /// location.fill 43 | public let location: Image 44 | 45 | /// paperclip 46 | public let document: Image 47 | 48 | /// music.note 49 | public let music: Image 50 | 51 | /// circle.dotted 52 | public let sending: Image 53 | 54 | /// checkmark.circle 55 | public let sent: Image 56 | 57 | /// checkmark.circle.fill 58 | public let delivered: Image 59 | 60 | /// exclamationmark.circle 61 | public let failed: Image 62 | 63 | /// icloud.slash 64 | public let downloadFailed: Image 65 | 66 | /// xmark.circle.fill 67 | public let close: Image 68 | 69 | /// arrow.triangle.2.circlepath 70 | public let flip: Image 71 | 72 | /// trash 73 | public let delete: Image 74 | 75 | /// pause.circle.fill 76 | public let pause: Image 77 | 78 | /// play.circle.fill 79 | public let play: Image 80 | 81 | /// person.crop.circle.fill 82 | public let person: Image 83 | 84 | /// Creates a new ``ChatSymbols``. 85 | public init( 86 | menu: Image = Image(systemName: "circle.grid.2x2.fill"), 87 | camera: Image = Image(systemName: "camera.fill"), 88 | photoLibrary: Image = Image(systemName: "photo"), 89 | mic: Image = Image(systemName: "mic.fill"), 90 | giphy: Image = Image(systemName: "face.smiling.fill"), 91 | send: Image = Image(systemName: "paperplane.fill"), 92 | buttonHidden: Image = Image(systemName: "chevron.right"), 93 | directionDown: Image = Image(systemName: "chevron.down"), 94 | location: Image = Image(systemName: "location.fill"), 95 | document: Image = Image(systemName: "paperclip"), 96 | music: Image = Image(systemName: "music.note"), 97 | sending: Image = Image(systemName: "circle.dotted"), 98 | sent: Image = Image(systemName: "checkmark.circle"), 99 | delivered: Image = Image(systemName: "checkmark.circle.fill"), 100 | failed: Image = Image(systemName: "exclamationmark.circle"), 101 | downloadFailed: Image = Image(systemName: "icloud.slash"), 102 | close: Image = Image(systemName: "xmark.circle.fill"), 103 | flip: Image = Image(systemName: "arrow.triangle.2.circlepath"), 104 | delete: Image = Image(systemName: "trash"), 105 | pause: Image = Image(systemName: "pause.circle.fill"), 106 | play: Image = Image(systemName: "play.circle.fill"), 107 | person: Image = Image(systemName: "person.crop.circle.fill") 108 | ) { 109 | self.menu = menu 110 | self.camera = camera 111 | self.photoLibrary = photoLibrary 112 | self.mic = mic 113 | self.giphy = giphy 114 | self.send = send 115 | self.buttonHidden = buttonHidden 116 | self.directionDown = directionDown 117 | self.location = location 118 | self.document = document 119 | self.music = music 120 | self.sending = sending 121 | self.sent = sent 122 | self.delivered = delivered 123 | self.failed = failed 124 | self.downloadFailed = downloadFailed 125 | self.close = close 126 | self.flip = flip 127 | self.delete = delete 128 | self.pause = pause 129 | self.play = play 130 | self.person = person 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /Sources/ChatUI/Appearance/ImageAppearanceScheme.ChatUI.swift: -------------------------------------------------------------------------------- 1 | /// Provides images from ``ChatSymbols`` with the specific color scheme. 2 | /// 3 | /// The initializer allows to set up the ``ChatSymbols`` for both light and dark mode. 4 | /// 5 | /// Example usage: 6 | /// ``` 7 | /// // To get ``ChatSymbols.send`` for dark mode 8 | /// ImageAsset.send(.dark) 9 | /// // To get ``ChatSybmols.camera`` for light mode 10 | /// ImageAsset.camera(.light) 11 | /// ``` 12 | 13 | import Foundation 14 | import SwiftUI 15 | 16 | public struct ImageAsset { 17 | 18 | private let darkSymbol: ChatSymbols 19 | private let lightSymbol: ChatSymbols 20 | 21 | public func menu(_ colorScheme: ColorScheme) -> Image { 22 | return colorScheme == .dark 23 | ? darkSymbol.menu 24 | : lightSymbol.menu 25 | } 26 | 27 | public func camera(_ colorScheme: ColorScheme) -> Image { 28 | return colorScheme == .dark 29 | ? darkSymbol.camera 30 | : lightSymbol.camera 31 | } 32 | 33 | public func photoLibrary(_ colorScheme: ColorScheme) -> Image { 34 | return colorScheme == .dark 35 | ? darkSymbol.photoLibrary 36 | : lightSymbol.photoLibrary 37 | } 38 | 39 | public func mic(_ colorScheme: ColorScheme) -> Image { 40 | return colorScheme == .dark 41 | ? darkSymbol.mic 42 | : lightSymbol.mic 43 | } 44 | 45 | public func giphy(_ colorScheme: ColorScheme) -> Image { 46 | return colorScheme == .dark 47 | ? darkSymbol.giphy 48 | : lightSymbol.giphy 49 | } 50 | 51 | public func send(_ colorScheme: ColorScheme) -> Image { 52 | return colorScheme == .dark 53 | ? darkSymbol.send 54 | : lightSymbol.send 55 | } 56 | 57 | public func buttonHidden(_ colorScheme: ColorScheme) -> Image { 58 | return colorScheme == .dark 59 | ? darkSymbol.buttonHidden 60 | : lightSymbol.buttonHidden 61 | } 62 | 63 | public func directionDown(_ colorScheme: ColorScheme) -> Image { 64 | return colorScheme == .dark 65 | ? darkSymbol.directionDown 66 | : lightSymbol.directionDown 67 | } 68 | 69 | public func location(_ colorScheme: ColorScheme) -> Image { 70 | return colorScheme == .dark 71 | ? darkSymbol.location 72 | : lightSymbol.location 73 | } 74 | 75 | public func document(_ colorScheme: ColorScheme) -> Image { 76 | return colorScheme == .dark 77 | ? darkSymbol.document 78 | : lightSymbol.document 79 | } 80 | 81 | public func music(_ colorScheme: ColorScheme) -> Image { 82 | return colorScheme == .dark 83 | ? darkSymbol.music 84 | : lightSymbol.music 85 | } 86 | 87 | public func sending(_ colorScheme: ColorScheme) -> Image { 88 | return colorScheme == .dark 89 | ? darkSymbol.sending 90 | : lightSymbol.sending 91 | } 92 | 93 | public func sent(_ colorScheme: ColorScheme) -> Image { 94 | return colorScheme == .dark 95 | ? darkSymbol.sent 96 | : lightSymbol.sent 97 | } 98 | 99 | public func delivered(_ colorScheme: ColorScheme) -> Image { 100 | return colorScheme == .dark 101 | ? darkSymbol.delivered 102 | : lightSymbol.delivered 103 | } 104 | 105 | public func failed(_ colorScheme: ColorScheme) -> Image { 106 | return colorScheme == .dark 107 | ? darkSymbol.failed 108 | : lightSymbol.failed 109 | } 110 | 111 | public func downloadFailed(_ colorScheme: ColorScheme) -> Image { 112 | return colorScheme == .dark 113 | ? darkSymbol.downloadFailed 114 | : lightSymbol.downloadFailed 115 | } 116 | 117 | public func close(_ colorScheme: ColorScheme) -> Image { 118 | return colorScheme == .dark 119 | ? darkSymbol.close 120 | : lightSymbol.close 121 | } 122 | 123 | public func flip(_ colorScheme: ColorScheme) -> Image { 124 | return colorScheme == .dark 125 | ? darkSymbol.flip 126 | : lightSymbol.flip 127 | } 128 | 129 | public func delete(_ colorScheme: ColorScheme) -> Image { 130 | return colorScheme == .dark 131 | ? darkSymbol.delete 132 | : lightSymbol.delete 133 | } 134 | 135 | public func pause(_ colorScheme: ColorScheme) -> Image { 136 | return colorScheme == .dark 137 | ? darkSymbol.pause 138 | : lightSymbol.pause 139 | } 140 | 141 | public func play(_ colorScheme: ColorScheme) -> Image { 142 | return colorScheme == .dark 143 | ? darkSymbol.play 144 | : lightSymbol.play 145 | } 146 | 147 | public func person(_ colorScheme: ColorScheme) -> Image { 148 | return colorScheme == .dark 149 | ? darkSymbol.person 150 | : lightSymbol.person 151 | } 152 | 153 | /// Creates a new ``ImageAsset`` with ``ChatSymbols`` objects. 154 | /// - Parameters: 155 | /// - lightSybmol: ``ChatSymbols`` object that is used for the light mode. 156 | /// - darkSymbol: ``ChatSymbols`` object that is used for the dark mode. 157 | public init(lightSymbol: ChatSymbols, 158 | darkSymbol: ChatSymbols) { 159 | self.lightSymbol = lightSymbol 160 | self.darkSymbol = darkSymbol 161 | } 162 | 163 | } 164 | 165 | -------------------------------------------------------------------------------- /Sources/ChatUI/Appearance/ImageScale.ChatUI.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImageScale.ChatUI.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/02/12. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension Image { 11 | /** 12 | Scales the view to the specified size while maintaining its aspect ratio. 13 | 14 | Use this method to resize an image or other view to a specific size while keeping its aspect ratio. 15 | 16 | - Parameters: 17 | - scale: The target size for the view, specified as a `CGSize`. 18 | - contentMode: The content mode to use when scaling the view. The default value is `ContentMode.aspectFit`. 19 | 20 | - Returns: A new view that scales the original view to the specified size. 21 | 22 | **Example usage:** 23 | 24 | ```swift 25 | Image("my-image") 26 | .scale(CGSize(width: 100, height: 100), contentMode: .fill) 27 | ``` 28 | 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. 29 | 30 | - 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. 31 | 32 | - SeeAlso: `resizable()`, `aspectRatio(contentMode:)`, `frame(width:height:)` 33 | */ 34 | public func scale(_ scale: CGSize, contentMode: ContentMode) -> some View { 35 | self 36 | .resizable() 37 | .aspectRatio(contentMode: contentMode) 38 | .frame(width: scale.width, height: scale.height) 39 | .clipped() 40 | } 41 | 42 | /// 16 x 16, `.fit` 43 | public var xSmall: some View { 44 | self 45 | .scale(.Image.xSmall, contentMode: .fit) 46 | } 47 | 48 | /// 16 x 16, `.fill` 49 | public var xSmall2: some View { 50 | self 51 | .scale(.Image.xSmall, contentMode: .fill) 52 | } 53 | 54 | /// 20 x 20, `.fit` 55 | public var small: some View { 56 | self 57 | .scale(.Image.small, contentMode: .fit) 58 | } 59 | 60 | /// 20 x 20, `.fill` 61 | public var small2: some View { 62 | self 63 | .scale(.Image.small, contentMode: .fill) 64 | } 65 | 66 | /// 24 x 24, `.fit` 67 | public var medium: some View { 68 | self 69 | .scale(.Image.medium, contentMode: .fit) 70 | } 71 | 72 | /// 24 x 24, `.fill` 73 | public var medium2: some View { 74 | self 75 | .scale(.Image.medium, contentMode: .fill) 76 | } 77 | 78 | /// 36 x 36, `.fit` 79 | public var large: some View { 80 | self 81 | .scale(.Image.large, contentMode: .fit) 82 | } 83 | 84 | /// 36 x 36, `.fill` 85 | public var large2: some View { 86 | self 87 | .scale(.Image.large, contentMode: .fill) 88 | } 89 | 90 | /// 48 x 48, `.fit` 91 | public var xLarge: some View { 92 | self 93 | .scale(.Image.xLarge, contentMode: .fit) 94 | } 95 | 96 | /// 48 x 48, `.fill` 97 | public var xLarge2: some View { 98 | self 99 | .scale(.Image.xLarge, contentMode: .fill) 100 | } 101 | 102 | /// 64 x 64, `.fit` 103 | public var xxLarge: some View { 104 | self 105 | .scale(.Image.xxLarge, contentMode: .fit) 106 | } 107 | 108 | /// 64 x 64, `.fill` 109 | public var xxLarge2: some View { 110 | self 111 | .scale(.Image.xxLarge, contentMode: .fill) 112 | } 113 | 114 | /// 90 x 90, `.fit` 115 | public var xxxLarge: some View { 116 | self 117 | .scale(.Image.xxxLarge, contentMode: .fit) 118 | } 119 | 120 | /// 90 x 90, `.fill` 121 | public var xxxLarge2: some View { 122 | self 123 | .scale(.Image.xxxLarge, contentMode: .fill) 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /Sources/ChatUI/Appearance/String.ChatUI.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String.ChatUI.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/02/09. 6 | // 7 | 8 | import Foundation 9 | 10 | extension String { 11 | public class MessageField { 12 | public static var placeholder: String = "Aa" 13 | } 14 | 15 | public class Message { 16 | public static var failedPhoto: String = "Couldn't Load Image " 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/ChatUI/ChatInChannel/ChannelInfoView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChannelInfoView.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/02/09. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /** 11 | The view that displays the following channel information: 12 | 13 | - The image of the channel 14 | - The title of the channel 15 | - The subtitle of the channel 16 | */ 17 | public struct ChannelInfoView: View { 18 | @Environment(\.colorScheme) var colorScheme 19 | @Environment(\.appearance) var appearance 20 | 21 | let imageURL: URL? 22 | let title: String 23 | let subtitle: String 24 | 25 | public var body: some View { 26 | HStack { 27 | AsyncImage(url: imageURL) { image in 28 | image.large2 29 | .clipShape(Circle()) 30 | .padding(1) 31 | .background { 32 | appearance.border 33 | .clipShape(Circle()) 34 | } 35 | } placeholder: { 36 | appearance.images.person(colorScheme).large2 37 | .foregroundColor(appearance.secondary) 38 | .clipShape(Circle()) 39 | } 40 | 41 | VStack(alignment: .leading) { 42 | Text(title) 43 | .font(appearance.title) 44 | .foregroundColor(appearance.primary) 45 | 46 | Text(subtitle) 47 | .font(appearance.subtitle) 48 | .foregroundColor(appearance.secondary) 49 | } 50 | } 51 | } 52 | 53 | public init(imageURL: URL?, title: String, subtitle: String) { 54 | self.imageURL = imageURL 55 | self.title = title 56 | self.subtitle = subtitle 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Sources/ChatUI/ChatInChannel/ChannelStack.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChannelStack.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/02/09. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /** 11 | The *vertical* stack view that provides ``ChannelInfoView`` as a `ToolbarItem`. 12 | 13 | ```swift 14 | ChannelStack(channel) { 15 | MessageList(messages) { 16 | MessageRow(message) 17 | } 18 | 19 | MessageField(isMenuItemPresented: $isPresented) { 20 | sendMessage(style: $0) 21 | } 22 | } 23 | ``` 24 | */ 25 | public struct ChannelStack: View { 26 | @EnvironmentObject private var configuration: ChatConfiguration 27 | 28 | let channel: ChannelType 29 | let content: () -> Content 30 | 31 | public var body: some View { 32 | VStack(spacing: 0) { 33 | content() 34 | } 35 | .toolbar { 36 | ToolbarItem(placement: .navigationBarLeading) { 37 | ChannelInfoView( 38 | imageURL: channel.imageURL, 39 | title: channel.name, 40 | subtitle: channel.id 41 | ) 42 | } 43 | } 44 | } 45 | 46 | public init( 47 | _ channel: ChannelType, 48 | @ViewBuilder content: @escaping () -> Content 49 | ) { 50 | self.channel = channel 51 | self.content = content 52 | } 53 | } 54 | 55 | /** 56 | VStack { 57 | MessageList(..) { ... } 58 | 59 | MessageField { .. } 60 | } 61 | .channelInfoBar { 62 | 63 | } 64 | */ 65 | -------------------------------------------------------------------------------- /Sources/ChatUI/ChatInChannel/MessageDateView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MessageDateView.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/03/08. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// The view that shows the messages year, month, day 11 | public struct MessageDateView: View { 12 | @Environment(\.appearance) var appearance 13 | 14 | let date: Date 15 | 16 | public var body: some View { 17 | Text(date, style: .date) 18 | .font(.caption) 19 | .foregroundColor(appearance.secondary) 20 | .padding(.top, 12) 21 | } 22 | 23 | public init(message: MessageType) { 24 | self.date = Date(timeIntervalSince1970: message.sentAt) 25 | } 26 | } 27 | 28 | extension MessageList { 29 | /// - Returns: The boolean value whether the `message` is different from the previous message. 30 | func showsDate(for message: MessageType) -> Bool { 31 | guard self.showsDate else { return false } 32 | var index: Int? 33 | switch message.readReceipt { 34 | case .sending: 35 | index = sendingMessages.firstIndex { $0.id == message.id} 36 | case .failed: 37 | return false 38 | case .sent: 39 | index = sentMessages.firstIndex { $0.id == message.id} 40 | case .delivered: 41 | index = deliveredMessages.firstIndex { $0.id == message.id} 42 | case .seen, .played: 43 | index = seenMessages.firstIndex { $0.id == message.id} 44 | } 45 | guard let index = index else { return true } 46 | 47 | var prevMessage: MessageType? 48 | switch message.readReceipt { 49 | case .sending: 50 | if index == sendingMessages.count - 1 { 51 | prevMessage = sentMessages.first 52 | } else { 53 | prevMessage = sendingMessages[index + 1] 54 | } 55 | case .failed: 56 | return false 57 | case .sent: 58 | if index == sentMessages.count - 1 { 59 | prevMessage = deliveredMessages.first 60 | } else { 61 | prevMessage = sentMessages[index + 1] 62 | } 63 | case .delivered: 64 | if index == deliveredMessages.count - 1 { 65 | prevMessage = seenMessages.first 66 | } else { 67 | prevMessage = deliveredMessages[index + 1] 68 | } 69 | case .seen, .played: 70 | guard index < seenMessages.count - 1 else { return true } 71 | prevMessage = seenMessages[index + 1] 72 | } 73 | guard let prevMessage = prevMessage else { return true } 74 | 75 | let curCreatedAt = message.sentAt 76 | let prevCreatedAt = prevMessage.sentAt 77 | 78 | return !(Date.from(prevCreatedAt).isSameDay(as: Date.from(curCreatedAt))) 79 | } 80 | } 81 | 82 | extension Date { 83 | static public func from(_ baseTimestamp: Double) -> Date { 84 | let timestampString = String(format: "%lld", baseTimestamp) 85 | let timeInterval = timestampString.count == 10 86 | ? TimeInterval(baseTimestamp) 87 | : TimeInterval(Double(baseTimestamp) / 1000.0) 88 | return Date(timeIntervalSince1970: timeInterval) 89 | } 90 | 91 | func isSameDay(as otherDate: Date) -> Bool { 92 | let baseDate = self 93 | let otherDate = otherDate 94 | 95 | let baseDateComponents = Calendar.current.dateComponents( 96 | [.day, .month, .year], 97 | from: baseDate 98 | ) 99 | let otherDateComponents = Calendar.current.dateComponents( 100 | [.day, .month, .year], 101 | from: otherDate 102 | ) 103 | 104 | if baseDateComponents.year == otherDateComponents.year, 105 | baseDateComponents.month == otherDateComponents.month, 106 | baseDateComponents.day == otherDateComponents.day { 107 | return true 108 | } 109 | else { 110 | return false 111 | } 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /Sources/ChatUI/ChatInChannel/MessageField/Camera/DataModel/Camera.AVCapturePhotoCaptureDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Camera.AVCapturePhotoCaptureDelegate.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/02/12. 6 | // 7 | 8 | import Combine 9 | import AVFoundation 10 | 11 | extension Camera: AVCapturePhotoCaptureDelegate { 12 | func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) { 13 | guard let data = photo.fileDataRepresentation() else { return } 14 | let _ = Empty() 15 | .sink { _ in 16 | capturedItemPublisher.send(.photo(data)) 17 | } receiveValue: { _ in } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/ChatUI/ChatInChannel/MessageField/Camera/DataModel/Camera.AVCaptureVideoDataOutputSampleBufferDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Camera.AVCaptureVideoDataOutputSampleBufferDelegate.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/02/12. 6 | // 7 | 8 | import AVFoundation 9 | import CoreImage 10 | 11 | extension Camera: AVCaptureVideoDataOutputSampleBufferDelegate { 12 | func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) { 13 | guard let pixelBuffer = sampleBuffer.imageBuffer else { return } 14 | 15 | if connection.isVideoOrientationSupported, 16 | let videoOrientation = videoOrientationFor(deviceOrientation) { 17 | connection.videoOrientation = videoOrientation 18 | } 19 | 20 | // selectedVideoPreviewStream?(CIImage(cvPixelBuffer: pixelBuffer)) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/ChatUI/ChatInChannel/MessageField/Camera/DataModel/Camera.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Camera.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/02/12. 6 | // 7 | 8 | import UIKit 9 | import SwiftUI 10 | import AVFoundation 11 | 12 | // TODO: remove 13 | import Combine 14 | 15 | class Camera: NSObject, ObservableObject { 16 | private let captureSession = AVCaptureSession() 17 | private var isCaptureSessionConfigured = false 18 | private var deviceInput: AVCaptureDeviceInput? 19 | private var photoOutput: AVCapturePhotoOutput? 20 | private var videoOutput: AVCaptureVideoDataOutput? 21 | private var sessionQueue: DispatchQueue = DispatchQueue(label: "session queue") 22 | 23 | private var allCaptureDevices: [AVCaptureDevice] { 24 | AVCaptureDevice.DiscoverySession( 25 | deviceTypes: [ 26 | .builtInTrueDepthCamera, .builtInDualCamera, .builtInDualWideCamera, .builtInWideAngleCamera, .builtInDualWideCamera 27 | ], 28 | mediaType: .video, 29 | position: .unspecified 30 | ) 31 | .devices 32 | } 33 | 34 | private var frontCaptureDevices: [AVCaptureDevice] { 35 | allCaptureDevices 36 | .filter { $0.position == .front } 37 | } 38 | 39 | private var backCaptureDevices: [AVCaptureDevice] { 40 | allCaptureDevices 41 | .filter { $0.position == .back } 42 | } 43 | 44 | private var captureDevices: [AVCaptureDevice] { 45 | var devices = [AVCaptureDevice]() 46 | #if os(macOS) || (os(iOS) && targetEnvironment(macCatalyst)) 47 | devices += allCaptureDevices 48 | #else 49 | if let backDevice = backCaptureDevices.first { 50 | devices += [backDevice] 51 | } 52 | if let frontDevice = frontCaptureDevices.first { 53 | devices += [frontDevice] 54 | } 55 | #endif 56 | return devices 57 | } 58 | 59 | private var availableCaptureDevices: [AVCaptureDevice] { 60 | captureDevices 61 | .filter( { $0.isConnected } ) 62 | .filter( { !$0.isSuspended } ) 63 | } 64 | 65 | private var captureDevice: AVCaptureDevice? { 66 | didSet { 67 | guard let captureDevice = captureDevice else { return } 68 | sessionQueue.async { 69 | self.updateSessionForCaptureDevice(captureDevice) 70 | } 71 | } 72 | } 73 | 74 | var isRunning: Bool { captureSession.isRunning } 75 | 76 | var isUsingFrontCaptureDevice: Bool { 77 | guard let captureDevice = captureDevice else { return false } 78 | return frontCaptureDevices.contains(captureDevice) 79 | } 80 | 81 | var isUsingBackCaptureDevice: Bool { 82 | guard let captureDevice = captureDevice else { return false } 83 | return backCaptureDevices.contains(captureDevice) 84 | } 85 | 86 | override init() { 87 | super.init() 88 | 89 | captureDevice = availableCaptureDevices.first ?? AVCaptureDevice.default(for: .video) 90 | } 91 | 92 | private func configureCaptureSession(completionHandler: (_ success: Bool) -> Void) { 93 | var success = false 94 | 95 | self.captureSession.beginConfiguration() 96 | 97 | defer { 98 | self.captureSession.commitConfiguration() 99 | completionHandler(success) 100 | } 101 | 102 | guard 103 | let captureDevice = captureDevice, 104 | let deviceInput = try? AVCaptureDeviceInput(device: captureDevice) 105 | else { return } 106 | 107 | let photoOutput = AVCapturePhotoOutput() 108 | 109 | captureSession.sessionPreset = AVCaptureSession.Preset.photo 110 | 111 | let videoOutput = AVCaptureVideoDataOutput() 112 | videoOutput.setSampleBufferDelegate(self, queue: DispatchQueue(label: "VideoDataOutputQueue")) 113 | 114 | guard captureSession.canAddInput(deviceInput) else { return } 115 | guard captureSession.canAddOutput(photoOutput) else { return } 116 | guard captureSession.canAddOutput(videoOutput) else { return } 117 | 118 | captureSession.addInput(deviceInput) 119 | captureSession.addOutput(photoOutput) 120 | captureSession.addOutput(videoOutput) 121 | 122 | self.deviceInput = deviceInput 123 | self.photoOutput = photoOutput 124 | self.videoOutput = videoOutput 125 | 126 | photoOutput.isHighResolutionCaptureEnabled = true 127 | photoOutput.maxPhotoQualityPrioritization = .quality 128 | 129 | updateVideoOutputConnection() 130 | 131 | isCaptureSessionConfigured = true 132 | 133 | success = true 134 | } 135 | 136 | private func checkAuthorization() async -> Bool { 137 | switch AVCaptureDevice.authorizationStatus(for: .video) { 138 | case .authorized: 139 | return true 140 | case .notDetermined: 141 | sessionQueue.suspend() 142 | let status = await AVCaptureDevice.requestAccess(for: .video) 143 | sessionQueue.resume() 144 | return status 145 | default: 146 | return false 147 | } 148 | } 149 | 150 | private func deviceInputFor(device: AVCaptureDevice?) -> AVCaptureDeviceInput? { 151 | guard let validDevice = device else { return nil } 152 | do { return try AVCaptureDeviceInput(device: validDevice) } 153 | catch { return nil } 154 | } 155 | 156 | private func updateSessionForCaptureDevice(_ captureDevice: AVCaptureDevice) { 157 | guard isCaptureSessionConfigured else { return } 158 | 159 | captureSession.beginConfiguration() 160 | defer { captureSession.commitConfiguration() } 161 | 162 | for input in captureSession.inputs { 163 | if let deviceInput = input as? AVCaptureDeviceInput { 164 | captureSession.removeInput(deviceInput) 165 | } 166 | } 167 | 168 | if let deviceInput = deviceInputFor(device: captureDevice) { 169 | if !captureSession.inputs.contains(deviceInput), captureSession.canAddInput(deviceInput) { 170 | captureSession.addInput(deviceInput) 171 | } 172 | } 173 | 174 | updateVideoOutputConnection() 175 | } 176 | 177 | private func updateVideoOutputConnection() { 178 | if let videoOutput = videoOutput, let videoOutputConnection = videoOutput.connection(with: .video) { 179 | if videoOutputConnection.isVideoMirroringSupported { 180 | videoOutputConnection.isVideoMirrored = isUsingFrontCaptureDevice 181 | } 182 | } 183 | } 184 | 185 | func start() async { 186 | let authorized = await checkAuthorization() 187 | guard authorized else { return } 188 | 189 | if isCaptureSessionConfigured { 190 | if !captureSession.isRunning { 191 | sessionQueue.async { [self] in 192 | self.captureSession.startRunning() 193 | } 194 | } 195 | return 196 | } 197 | 198 | sessionQueue.async { [self] in 199 | self.configureCaptureSession { success in 200 | guard success else { return } 201 | self.captureSession.startRunning() 202 | } 203 | } 204 | } 205 | 206 | func stop() { 207 | guard isCaptureSessionConfigured else { return } 208 | 209 | if captureSession.isRunning { 210 | sessionQueue.async { 211 | self.captureSession.stopRunning() 212 | } 213 | } 214 | } 215 | 216 | func switchCaptureDevice() { 217 | if let captureDevice = captureDevice, let index = availableCaptureDevices.firstIndex(of: captureDevice) { 218 | let nextIndex = (index + 1) % availableCaptureDevices.count 219 | self.captureDevice = availableCaptureDevices[nextIndex] 220 | } else { 221 | self.captureDevice = AVCaptureDevice.default(for: .video) 222 | } 223 | } 224 | 225 | var deviceOrientation: UIDeviceOrientation { 226 | var orientation = UIDevice.current.orientation 227 | if orientation == UIDeviceOrientation.unknown { 228 | orientation = UIScreen.main.orientation 229 | } 230 | return orientation 231 | } 232 | 233 | func videoOrientationFor(_ deviceOrientation: UIDeviceOrientation) -> AVCaptureVideoOrientation? { 234 | switch deviceOrientation { 235 | case .portrait: return AVCaptureVideoOrientation.portrait 236 | case .portraitUpsideDown: return AVCaptureVideoOrientation.portraitUpsideDown 237 | case .landscapeLeft: return AVCaptureVideoOrientation.landscapeRight 238 | case .landscapeRight: return AVCaptureVideoOrientation.landscapeLeft 239 | default: return nil 240 | } 241 | } 242 | 243 | func takePhoto() { 244 | // TODO: remove 245 | if let data = (try? Data(contentsOf: URL(string: "https://picsum.photos/220")!)) { 246 | let _ = Empty() 247 | .sink { _ in 248 | capturedItemPublisher.send(.photo(data)) 249 | } receiveValue: { _ in } 250 | } 251 | 252 | guard let photoOutput = self.photoOutput else { return } 253 | 254 | sessionQueue.async { 255 | 256 | var photoSettings = AVCapturePhotoSettings() 257 | 258 | if photoOutput.availablePhotoCodecTypes.contains(.hevc) { 259 | photoSettings = AVCapturePhotoSettings(format: [AVVideoCodecKey: AVVideoCodecType.hevc]) 260 | } 261 | 262 | let isFlashAvailable = self.deviceInput?.device.isFlashAvailable ?? false 263 | photoSettings.flashMode = isFlashAvailable ? .auto : .off 264 | photoSettings.isHighResolutionPhotoEnabled = true 265 | if let previewPhotoPixelFormatType = photoSettings.availablePreviewPhotoPixelFormatTypes.first { 266 | photoSettings.previewPhotoFormat = [kCVPixelBufferPixelFormatTypeKey as String: previewPhotoPixelFormatType] 267 | } 268 | photoSettings.photoQualityPrioritization = .balanced 269 | 270 | if let photoOutputVideoConnection = photoOutput.connection(with: .video) { 271 | if photoOutputVideoConnection.isVideoOrientationSupported, 272 | let videoOrientation = self.videoOrientationFor(self.deviceOrientation) { 273 | photoOutputVideoConnection.videoOrientation = videoOrientation 274 | } 275 | } 276 | 277 | photoOutput.capturePhoto(with: photoSettings, delegate: self) 278 | } 279 | } 280 | } 281 | 282 | 283 | fileprivate extension UIScreen { 284 | 285 | var orientation: UIDeviceOrientation { 286 | let point = coordinateSpace.convert(CGPoint.zero, to: fixedCoordinateSpace) 287 | if point == CGPoint.zero { 288 | return .portrait 289 | } else if point.x != 0 && point.y != 0 { 290 | return .portraitUpsideDown 291 | } else if point.x == 0 && point.y != 0 { 292 | return .landscapeRight //.landscapeLeft 293 | } else if point.x != 0 && point.y == 0 { 294 | return .landscapeLeft //.landscapeRight 295 | } else { 296 | return .unknown 297 | } 298 | } 299 | } 300 | -------------------------------------------------------------------------------- /Sources/ChatUI/ChatInChannel/MessageField/Camera/View/CameraField.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CameraField.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/02/12. 6 | // 7 | 8 | import SwiftUI 9 | import Combine 10 | 11 | public struct CameraField: View { 12 | @Environment(\.colorScheme) var colorScheme 13 | @Environment(\.appearance) var appearance 14 | 15 | @State private var isCameraViewPresented: Bool = true 16 | @State private var capturedItem: CapturedItemView.CaptureType? 17 | 18 | @Binding var isPresented: Bool 19 | 20 | public var body: some View { 21 | VStack { 22 | if let capturedItem = capturedItem { 23 | // if let imageData = dataModel.capturedPhotoData?.fileDataRepresentation() { 24 | CapturedItemView(itemType: capturedItem) 25 | .frame(width: UIScreen.main.bounds.width) 26 | } else { 27 | appearance.secondary 28 | .overlay { 29 | VStack { 30 | appearance.images.downloadFailed(colorScheme).xLarge 31 | .clipped() 32 | 33 | Text(String.Message.failedPhoto) 34 | .font(appearance.footnote.bold()) 35 | } 36 | .foregroundColor(appearance.secondary) 37 | } 38 | .frame( 39 | width: UIScreen.main.bounds.width, 40 | height: UIScreen.main.bounds.width 41 | ) 42 | } 43 | 44 | HStack { 45 | Button(action: retake) { 46 | appearance.images.camera(colorScheme).medium 47 | } 48 | .tint(appearance.tint) 49 | .frame(width: 36, height: 36) 50 | 51 | Spacer() 52 | 53 | Button(action: send) { 54 | HStack { 55 | appearance.images.send(colorScheme).xSmall 56 | 57 | Text("Send") 58 | .font(appearance.footnote.bold()) 59 | } 60 | .frame(height: 24) 61 | .foregroundColor(.white) 62 | .padding(.vertical, 6) 63 | .padding(.horizontal, 18) 64 | .background { 65 | appearance.tint 66 | .clipShape(Capsule()) 67 | } 68 | } 69 | } 70 | .padding(16) 71 | } 72 | .background { Color(.systemBackground) } 73 | .fullScreenCover(isPresented: $isCameraViewPresented) { 74 | CameraView { 75 | isPresented = false 76 | } 77 | } 78 | .onReceive(capturedItemPublisher) { capturedItem in 79 | self.capturedItem = capturedItem 80 | self.isCameraViewPresented = false 81 | } 82 | } 83 | 84 | func retake() { 85 | isCameraViewPresented = true 86 | } 87 | 88 | func send() { 89 | let _ = Empty() 90 | .sink { _ in 91 | guard let capturedItem = capturedItem else { return } 92 | let style: MessageStyle 93 | switch capturedItem { 94 | case .photo(let data): 95 | style = MessageStyle.media(.photo(data)) 96 | case .video(let data): 97 | style = MessageStyle.media(.video(data)) 98 | } 99 | sendMessagePublisher.send(style) 100 | } receiveValue: { _ in } 101 | withAnimation { 102 | isPresented = false 103 | } 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /Sources/ChatUI/ChatInChannel/MessageField/Camera/View/CameraView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CameraView.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/02/12. 6 | // 7 | 8 | import SwiftUI 9 | import CoreImage 10 | import AVFoundation 11 | 12 | public struct CameraView: View { 13 | @Environment(\.colorScheme) var colorScheme 14 | @Environment(\.appearance) var appearance 15 | @Environment(\.dismiss) private var dismiss 16 | 17 | @StateObject private var dataModel = Camera() 18 | 19 | let onDismiss: () -> Void 20 | 21 | public var body: some View { 22 | VStack() { 23 | HStack { 24 | Button(action: { 25 | dismiss() 26 | onDismiss() 27 | }) { 28 | appearance.images.close(colorScheme).medium 29 | .foregroundColor(appearance.prominent) 30 | } 31 | 32 | Spacer() 33 | 34 | Button(action: dataModel.switchCaptureDevice) { 35 | appearance.images.flip(colorScheme).medium 36 | .foregroundColor(appearance.prominent) 37 | } 38 | } 39 | 40 | Spacer() 41 | 42 | Button(action: dataModel.takePhoto) { 43 | ZStack { 44 | Circle() 45 | .strokeBorder(appearance.prominent, lineWidth: 3) 46 | .frame(width: 62, height: 62) 47 | Circle() 48 | .fill(appearance.prominent) 49 | .frame(width: 50, height: 50) 50 | } 51 | } 52 | 53 | } 54 | .buttonStyle(.plain) 55 | .padding(.vertical, 64) 56 | .padding(.horizontal, 16) 57 | .background { Color.black } 58 | .ignoresSafeArea() 59 | .task { await dataModel.start() } 60 | } 61 | 62 | public init(onDismiss: @escaping () -> Void) { 63 | self.onDismiss = onDismiss 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Sources/ChatUI/ChatInChannel/MessageField/Camera/View/CapturedItemView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CapturedItemView.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/02/12. 6 | // 7 | 8 | import SwiftUI 9 | import Combine 10 | 11 | public struct CapturedItemView: View { 12 | @Environment(\.colorScheme) var colorScheme 13 | @Environment(\.appearance) var appearance 14 | 15 | let itemType: CaptureType 16 | 17 | public enum CaptureType { 18 | case photo(_ data: Data) 19 | case video(_ data: Data) 20 | } 21 | 22 | public var body: some View { 23 | switch itemType { 24 | case .photo(let data): 25 | if let uiImage = UIImage(data: data) { 26 | Image(uiImage: uiImage) 27 | .resizable() 28 | .scaledToFill() 29 | } else { 30 | failedImage 31 | } 32 | case .video(let data): 33 | failedImage 34 | } 35 | } 36 | 37 | var failedImage: some View { 38 | Color(uiColor: .secondarySystemBackground) 39 | .overlay { 40 | VStack { 41 | appearance.images.downloadFailed(colorScheme).xLarge 42 | 43 | Text(String.Message.failedPhoto) 44 | .font(appearance.footnote.bold()) 45 | } 46 | .foregroundColor(appearance.secondary) 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Sources/ChatUI/ChatInChannel/MessageField/Giphy/View/GiphyMediaView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GiphyMediaView.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/02/11. 6 | // 7 | 8 | import SwiftUI 9 | import GiphyUISDK 10 | 11 | /// The view that corresponds to `GPHMediaView` which shows `GPHMedia`. This view is used in ``GiphyStyleView`` for GIF message. 12 | /// - NOTE: It's referred to [github.com/Giphy/giphy-ios-sdk](https://github.com/Giphy/giphy-ios-sdk/blob/main/Docs.md#gphmediaview) 13 | struct GiphyMediaView: UIViewRepresentable { 14 | let gifID: String 15 | @State private var media: GPHMedia? 16 | @Binding var aspectRatio: CGFloat 17 | 18 | func makeUIView(context: Context) -> GPHMediaView { 19 | let mediaView = GPHMediaView() 20 | GiphyCore.shared.gifByID(gifID) { (response, error) in 21 | if let media = response?.data { 22 | DispatchQueue.main.sync { [self] in 23 | self.aspectRatio = media.aspectRatio 24 | self.media = media 25 | } 26 | } 27 | } 28 | return mediaView 29 | } 30 | 31 | func updateUIView(_ uiView: GPHMediaView, context: Context) { 32 | uiView.media = media 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/ChatUI/ChatInChannel/MessageField/Giphy/View/GiphyPicker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GiphyPicker.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/02/11. 6 | // 7 | 8 | import UIKit 9 | import SwiftUI 10 | import Combine 11 | import GiphyUISDK 12 | 13 | /** 14 | Picker for GIF provided by Giphy. 15 | 16 | - NOTE: It's referred to [github.com/Giphy/giphy-ios-sdk/issues/44](https://github.com/Giphy/giphy-ios-sdk/issues/44#issuecomment-1345202630) 17 | 18 | - IMPORTANT: When a GIF media is selected, ``sendMessagePublisher`` delivers the ID of the media via ``MessageStyle/media(_:)`` as an associated value. 19 | 20 | ```swift 21 | @EnvironmentObject var configuration: ChatConfiguration 22 | 23 | ... 24 | if let giphyKey = configuration.giphyKey { 25 | GiphyPicker(giphyKey: giphyKey) 26 | } 27 | ``` 28 | */ 29 | public struct GiphyPicker: UIViewControllerRepresentable { 30 | @Environment(\.colorScheme) var colorScheme 31 | @Environment(\.dismiss) private var dismiss 32 | 33 | let giphyKey: String 34 | var giphyConfig: GiphyConfiguration 35 | 36 | public func makeUIViewController(context: Context) -> GiphyViewController { 37 | Giphy.configure(apiKey: giphyKey) 38 | 39 | let controller = GiphyViewController() 40 | controller.swiftUIEnabled = true 41 | controller.mediaTypeConfig = giphyConfig.mediaTypeConfig 42 | controller.dimBackground = giphyConfig.dimBackground 43 | controller.showConfirmationScreen = giphyConfig.showConfirmationScreen 44 | controller.shouldLocalizeSearch = giphyConfig.shouldLocalizeSearch 45 | controller.delegate = context.coordinator 46 | controller.navigationController?.isNavigationBarHidden = true 47 | controller.navigationController?.setNavigationBarHidden(true, animated: false) 48 | 49 | GiphyViewController.trayHeightMultiplier = 1.0 50 | 51 | controller.theme = GPHTheme( 52 | type: colorScheme == .light 53 | ? .lightBlur 54 | : .darkBlur 55 | ) 56 | 57 | return controller 58 | } 59 | 60 | public func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) { 61 | 62 | } 63 | 64 | public func makeCoordinator() -> Coordinator { 65 | return GiphyPicker.Coordinator(parent: self) 66 | } 67 | 68 | public class Coordinator: NSObject, GiphyDelegate { 69 | 70 | var parent: GiphyPicker 71 | 72 | init(parent: GiphyPicker) { 73 | self.parent = parent 74 | } 75 | 76 | public func didDismiss(controller: GiphyViewController?) { 77 | self.parent.dismiss() 78 | } 79 | 80 | public func didSelectMedia(giphyViewController: GiphyViewController, media: GPHMedia) { 81 | // media.id 82 | DispatchQueue.main.async { 83 | let _ = Empty() 84 | .sink( 85 | receiveCompletion: { _ in 86 | let style = MessageStyle.media( 87 | .gif(media.id) 88 | ) 89 | sendMessagePublisher.send(style) 90 | }, 91 | receiveValue: { _ in } 92 | ) 93 | 94 | self.parent.dismiss() 95 | } 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Sources/ChatUI/ChatInChannel/MessageField/Location/DataModel/LocationModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LocationModel.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/02/11. 6 | // 7 | 8 | import MapKit 9 | import SwiftUI 10 | import Combine 11 | 12 | class LocationModel: NSObject, ObservableObject { 13 | let manager = CLLocationManager() 14 | 15 | enum TrackStatus { 16 | case none 17 | case notAllowed 18 | case tracking 19 | case tracked 20 | } 21 | 22 | @Published var locationTrackingStatus: TrackStatus = .none 23 | @Published var coordinateRegion: MKCoordinateRegion = .init( 24 | center: .init(latitude: 37.57827, longitude: 126.97695), 25 | span: .init(latitudeDelta: 0.005, longitudeDelta: 0.005) 26 | ) 27 | 28 | // MARK: Search 29 | @Published var searchText: String = "" 30 | @Published var searchedResults: [MKMapItem] = [] 31 | var searchPublisher: AnyCancellable? 32 | var searchResultPublisher: AnyCancellable? 33 | 34 | let fixedRegion = MKCoordinateRegion( 35 | center: .init(latitude: 37.57827, longitude: 126.97695), 36 | span: .init(latitudeDelta: 0.005, longitudeDelta: 0.005) 37 | ) 38 | 39 | override init() { 40 | super.init() 41 | 42 | manager.delegate = self 43 | subscribeSearchPublisher() 44 | subscribesSearchResultPublisher() 45 | } 46 | 47 | deinit { 48 | searchPublisher?.cancel() 49 | searchResultPublisher?.cancel() 50 | } 51 | } 52 | 53 | // MARK: - CLLocationManagerDelegate 54 | extension LocationModel: CLLocationManagerDelegate { 55 | func requestLocation() { 56 | withAnimation { 57 | locationTrackingStatus = .tracking 58 | } 59 | manager.requestLocation() 60 | } 61 | 62 | func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { 63 | // Set up region once. 64 | guard locationTrackingStatus != .tracked else { return } 65 | guard let location = locations.first?.coordinate else { return } 66 | coordinateRegion = .init( 67 | center: location, 68 | span: .init(latitudeDelta: 0.005, longitudeDelta: 0.005) 69 | ) 70 | locationTrackingStatus = .tracked 71 | } 72 | 73 | func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { 74 | print("🧭 [Location] error: \(error.localizedDescription)") 75 | } 76 | 77 | func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) { 78 | if status == .authorizedWhenInUse { 79 | requestLocation() 80 | } else { 81 | locationTrackingStatus = .notAllowed 82 | } 83 | } 84 | } 85 | 86 | extension LocationModel { 87 | func subscribeSearchPublisher() { 88 | searchPublisher = $searchText 89 | .debounce(for: 0.5, scheduler: RunLoop.main) 90 | .compactMap { $0 } 91 | .sink(receiveValue: { [weak self] (searchText) in 92 | self?.searchedResults = [] 93 | self?.search(searchText) 94 | }) 95 | } 96 | 97 | func subscribesSearchResultPublisher() { 98 | searchResultPublisher = locationSearchResultPublisher 99 | .sink(receiveValue: { [weak self] items in 100 | self?.searchedResults = items 101 | }) 102 | } 103 | 104 | func search(_ text: String) { 105 | let request = MKLocalSearch.Request() 106 | request.naturalLanguageQuery = searchText 107 | request.pointOfInterestFilter = .includingAll 108 | request.resultTypes = [.pointOfInterest] 109 | request.region = coordinateRegion 110 | let search = MKLocalSearch(request: request) 111 | 112 | search.start { (response, _) in 113 | guard let response = response else { return } 114 | locationSearchResultPublisher.send(response.mapItems) 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /Sources/ChatUI/ChatInChannel/MessageField/Location/View/LocationSelector.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LocationSelector.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/02/11. 6 | // 7 | 8 | import MapKit 9 | import SwiftUI 10 | import Combine 11 | import CoreLocationUI 12 | 13 | public struct LocationSelector: View { 14 | @Environment(\.colorScheme) var colorScheme 15 | @Environment(\.appearance) var appearance 16 | 17 | @StateObject var dataModel = LocationModel() 18 | @Binding var isPresented: Bool 19 | 20 | let fade = Gradient(colors: [Color.black, Color.clear]) 21 | 22 | public var body: some View { 23 | switch dataModel.locationTrackingStatus { 24 | case .none: 25 | EmptyView() 26 | case .tracked: 27 | VStack(spacing: 0) { 28 | ZStack { 29 | Map(coordinateRegion: $dataModel.coordinateRegion) 30 | 31 | SendLocationButton(action: send) 32 | 33 | Circle() 34 | .frame(width: 8, height: 8) 35 | .foregroundColor(appearance.tint) 36 | } 37 | 38 | // Search results 39 | if !dataModel.searchedResults.isEmpty { 40 | ScrollView(.horizontal, showsIndicators: false) { 41 | LazyHStack { 42 | ForEach(dataModel.searchedResults, id: \.self) { resultItem in 43 | Button { 44 | dataModel.coordinateRegion.center = resultItem.placemark.coordinate 45 | } label: { 46 | VStack(alignment: .leading) { 47 | Text(resultItem.name ?? "") 48 | .foregroundColor(.primary) 49 | .font(.subheadline) 50 | 51 | Text( 52 | resultItem.placemark.subLocality 53 | ?? resultItem.placemark.locality 54 | ?? resultItem.placemark.administrativeArea 55 | ?? "" 56 | ) 57 | .font(appearance.caption) 58 | .foregroundColor(appearance.secondary) 59 | } 60 | } 61 | .padding(.horizontal, 12) 62 | 63 | Divider() 64 | } 65 | } 66 | .padding(8) 67 | } 68 | .frame(height: 40) 69 | .padding(.top, 8) 70 | } 71 | 72 | // Dimiss button & Search bar 73 | HStack { 74 | Button(action: dismiss) { 75 | appearance.images.close(colorScheme).medium 76 | } 77 | .tint(appearance.tint) 78 | .frame(width: 36, height: 36) 79 | 80 | TextField("Search location...", text: $dataModel.searchText) 81 | .frame(height: 36) 82 | .padding(.horizontal, 18) 83 | .background { 84 | appearance.secondaryBackground 85 | .clipShape(Capsule()) 86 | } 87 | } 88 | .padding(.top, dataModel.searchedResults.isEmpty ? 16 : 8) 89 | .padding(.horizontal, 16) 90 | .padding(.bottom, 16) 91 | } 92 | .background { appearance.background } 93 | case .notAllowed, .tracking: 94 | VStack(spacing: 8) { 95 | ZStack(alignment: .bottom) { 96 | ZStack { 97 | Map(coordinateRegion: .constant(dataModel.fixedRegion)) 98 | .disabled(true) 99 | .mask(LinearGradient(gradient: fade, startPoint: .top, endPoint: .bottom)) 100 | 101 | appearance.images.location(colorScheme).medium 102 | .foregroundColor(appearance.prominent) 103 | .padding() 104 | .background { 105 | Circle() 106 | .foregroundColor(appearance.tint) 107 | } 108 | .padding() 109 | .background { 110 | Circle() 111 | .foregroundColor(appearance.tint.opacity(0.2)) 112 | } 113 | .padding() 114 | .background { 115 | Circle() 116 | .foregroundColor(appearance.tint.opacity(0.1)) 117 | } 118 | } 119 | 120 | Text( 121 | dataModel.locationTrackingStatus == .notAllowed 122 | ? "Enable your location" 123 | : "Send your location" 124 | ) 125 | .font(appearance.title) 126 | .foregroundColor(appearance.primary) 127 | } 128 | .frame(height: 200) 129 | 130 | if dataModel.locationTrackingStatus == .notAllowed { 131 | 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") 132 | .multilineTextAlignment(.center) 133 | .font(appearance.subtitle) 134 | .foregroundColor(appearance.secondary) 135 | .padding(.bottom, 12) 136 | 137 | LocationButton(action: dataModel.requestLocation) 138 | .foregroundColor(appearance.prominent) 139 | .tint(appearance.tint) 140 | .clipShape(Capsule()) 141 | 142 | Button(action: dismiss) { 143 | Text("Cancel") 144 | .font(appearance.footnote) 145 | } 146 | .tint(appearance.tint) 147 | .padding(16) 148 | } else { 149 | Text("Loading ...") 150 | .font(appearance.footnote) 151 | .foregroundColor(appearance.secondary) 152 | .padding(.bottom, 12) 153 | } 154 | } 155 | .background { appearance.background } 156 | } 157 | } 158 | 159 | func send() { 160 | let coordinate = dataModel.coordinateRegion.center 161 | 162 | let _ = Empty() 163 | .sink( 164 | receiveCompletion: { _ in 165 | let style = MessageStyle.media( 166 | .location( 167 | coordinate.latitude, 168 | coordinate.longitude 169 | ) 170 | ) 171 | sendMessagePublisher.send(style) 172 | }, 173 | receiveValue: { _ in } 174 | ) 175 | isPresented = false 176 | } 177 | 178 | func dismiss() { 179 | withAnimation { 180 | isPresented = false 181 | } 182 | } 183 | 184 | public init(isPresented: Binding) { 185 | self._isPresented = isPresented 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /Sources/ChatUI/ChatInChannel/MessageField/Location/View/SendLocationButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SendLocationButton.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/02/11. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct SendLocationButton: View { 11 | @Environment(\.colorScheme) var colorScheme 12 | @Environment(\.appearance) var appearance 13 | 14 | let action: () -> Void 15 | 16 | var body: some View { 17 | Button(action: action) { 18 | VStack(spacing: 0) { 19 | Circle() 20 | .foregroundColor(appearance.tint) 21 | .frame(width: 48, height: 48) 22 | .overlay { 23 | appearance.images.send(colorScheme).medium 24 | .foregroundColor(appearance.prominent) 25 | } 26 | 27 | Image(systemName: "arrowtriangle.down.fill") 28 | .font(appearance.footnote) 29 | .cornerRadius(2) 30 | .foregroundColor(appearance.tint) 31 | .offset(x: 0, y: -5) 32 | .padding(.bottom, 48 + 5 + 12) 33 | } 34 | } 35 | } 36 | 37 | public init(action: @escaping () -> Void) { 38 | self.action = action 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Sources/ChatUI/ChatInChannel/MessageField/MessageField.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MessageField.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/02/08. 6 | // 7 | 8 | import Combine 9 | import SwiftUI 10 | import PhotosUI 11 | 12 | /** 13 | The view for sending messages. 14 | 15 | 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. 16 | 17 | ```swift 18 | MessageField { messageStyle in 19 | viewModel.sendMessage($0) 20 | } 21 | ``` 22 | 23 | To handle menu items, assign state property to `isMenuItemPresented` parameter. 24 | 25 | ```swift 26 | MessageField(isMenuItemPresented: $isMenuItemPresented) { ... } 27 | 28 | if isMenuItemPresented { 29 | MyMenuItemList() 30 | } 31 | ``` 32 | 33 | To publish a new message, you can create a new `MessageStyle` object and send it using `send(_:)`. 34 | 35 | ```swift 36 | // Create `MessageStyle` object 37 | let style = MessageStyle.text("{TEXT}") 38 | // Publish the created style object via `send(_:)` 39 | sendMessagePublisher.send(style) 40 | ``` 41 | 42 | You can subscribe to `sendMessagePublisher` to handle new messages. 43 | 44 | ```swift 45 | .onReceive(sendMessagePublisher) { messageStyle in 46 | // Handle `messageStyle` here (e.g., sending message with the style) 47 | } 48 | ``` 49 | */ 50 | public struct MessageField: View { 51 | 52 | @EnvironmentObject private var configuration: ChatConfiguration 53 | @Environment(\.colorScheme) var colorScheme 54 | @Environment(\.appearance) var appearance 55 | 56 | @FocusState private var isTextFieldFocused: Bool 57 | @State private var text: String = "" 58 | @State private var textFieldHeight: CGFloat = 20 59 | @State private var giphyKey: String? 60 | @State private var mediaData: Data? 61 | 62 | // Media 63 | @State private var selectedItem: PhotosPickerItem? = nil 64 | 65 | @Binding var isMenuItemPresented: Bool 66 | 67 | @State private var isGIFPickerPresented: Bool = false 68 | @State private var isCameraFieldPresented: Bool = false 69 | @State private var isVoiceFieldPresented: Bool = false 70 | 71 | let options: [MessageOption] 72 | let showsSendButtonAlways: Bool 73 | let characterLimit: Int? 74 | let onSend: (_ messageStyle: MessageStyle) -> () 75 | 76 | private var leftSideOptions: [MessageOption] { 77 | options.filter { $0 != .giphy } 78 | } 79 | 80 | public var body: some View { 81 | ZStack(alignment: .bottom) { 82 | HStack(alignment: .bottom) { 83 | if isTextFieldFocused, leftSideOptions.count > 1 { 84 | Button(action: onTapHiddenButton) { 85 | appearance.images.buttonHidden(colorScheme).medium 86 | .tint(appearance.tint) 87 | } 88 | .frame(width: 36, height: 36) 89 | } else { 90 | if options.contains(.menu) { 91 | // More Button 92 | Button(action: onTapMore) { 93 | appearance.images.menu(colorScheme).medium 94 | } 95 | .tint(appearance.tint) 96 | .frame(width: 36, height: 36) 97 | } 98 | 99 | // Camera Button 100 | if options.contains(.camera) { 101 | Button(action: onTapCamera) { 102 | appearance.images.camera(colorScheme).medium 103 | } 104 | .tint(appearance.tint) 105 | .disabled(isMenuItemPresented) 106 | .frame(width: 36, height: 36) 107 | } 108 | 109 | // Photo Library Button 110 | if options.contains(.photoLibrary) { 111 | PhotosPicker( 112 | selection: $selectedItem, 113 | matching: .images, 114 | photoLibrary: .shared() 115 | ) { 116 | appearance.images.photoLibrary(colorScheme).medium 117 | } 118 | .tint(appearance.tint) 119 | .disabled(isMenuItemPresented) 120 | .frame(width: 36, height: 36) 121 | .onChange(of: selectedItem) { newItem in 122 | Task { 123 | // Retrive selected asset in the form of Data 124 | if let data = try? await newItem?.loadTransferable(type: Data.self) { 125 | self.onSelectPhoto(data: data) 126 | } 127 | } 128 | } 129 | } 130 | 131 | // Mic Button 132 | if options.contains(.mic) { 133 | Button(action: onTapMic) { 134 | appearance.images.mic(colorScheme).medium 135 | } 136 | .tint(appearance.tint) 137 | .disabled(isMenuItemPresented) 138 | .frame(width: 36, height: 36) 139 | } 140 | } 141 | 142 | // TextField 143 | HStack(alignment: .bottom) { 144 | MessageTextField(text: $text, height: $textFieldHeight, characterLimit: characterLimit) 145 | .frame(height: textFieldHeight < 90 ? textFieldHeight : 90) 146 | .padding(.leading, 9) 147 | .padding(.trailing, 4) 148 | .focused($isTextFieldFocused) 149 | 150 | // Giphy Button 151 | if options.contains(.giphy) { 152 | Button(action: onTapGiphy) { 153 | appearance.images.giphy(colorScheme).medium 154 | } 155 | .tint(appearance.tint) 156 | .disabled(isMenuItemPresented) 157 | } 158 | } 159 | .padding(6) 160 | .background { 161 | appearance.secondaryBackground 162 | .clipShape(RoundedRectangle(cornerRadius: 18)) 163 | } 164 | 165 | // Send Button 166 | if showsSendButtonAlways || !text.isEmpty { 167 | Button(action: onTapSend) { 168 | appearance.images.send(colorScheme).medium 169 | } 170 | .frame(width: 36, height: 36) 171 | .tint(appearance.tint) 172 | .disabled(text.isEmpty) 173 | } 174 | } 175 | 176 | if isVoiceFieldPresented { 177 | VoiceField(isPresented: $isVoiceFieldPresented) 178 | } 179 | 180 | if isCameraFieldPresented { 181 | CameraField(isPresented: $isCameraFieldPresented) 182 | } 183 | } 184 | .sheet(isPresented: $isGIFPickerPresented) { 185 | if let giphyKey = configuration.giphyKey { 186 | GiphyPicker( 187 | giphyKey: giphyKey, 188 | giphyConfig: configuration.giphyConfig 189 | ) 190 | .ignoresSafeArea() 191 | .presentationDetents( 192 | [.fraction(configuration.giphyConfig.presentationDetents)] 193 | ) 194 | .presentationDragIndicator(.hidden) 195 | } else { 196 | Text("No Giphy Key") 197 | } 198 | } 199 | .onReceive(sendMessagePublisher) { messageStyle in 200 | onSend(messageStyle) 201 | } 202 | } 203 | 204 | 205 | public init( 206 | options: [MessageOption] = MessageOption.all, 207 | showsSendButtonAlways: Bool = false, 208 | characterLimit: Int? = nil, 209 | isMenuItemPresented: Binding = .constant(false), 210 | onSend: @escaping (_ messageStyle: MessageStyle) -> () 211 | ) { 212 | self.options = options 213 | self.showsSendButtonAlways = showsSendButtonAlways 214 | self.characterLimit = characterLimit 215 | self._isMenuItemPresented = isMenuItemPresented 216 | self.onSend = onSend 217 | } 218 | 219 | func onTapHiddenButton() { 220 | isTextFieldFocused = false 221 | } 222 | 223 | // TODO: Publishers: To customize buttons in message field and connect actions to appropriate publishers 224 | func onTapMore() { 225 | isMenuItemPresented.toggle() 226 | } 227 | 228 | func onTapCamera() { 229 | dismissMenuItems() 230 | isCameraFieldPresented = true 231 | } 232 | 233 | func onSelectPhoto(data: Data) { 234 | onSend(.media(.photo(data))) 235 | } 236 | 237 | func onTapMic() { 238 | dismissMenuItems() 239 | isVoiceFieldPresented = true 240 | } 241 | 242 | /// Shows ``GiphyPicker`` 243 | func onTapGiphy() { 244 | dismissMenuItems() 245 | isGIFPickerPresented = true 246 | } 247 | 248 | func onTapSend() { 249 | guard !text.isEmpty else { return } 250 | onSend(.text(text)) 251 | text = "" 252 | } 253 | 254 | func dismissMenuItems() { 255 | isMenuItemPresented = false 256 | isCameraFieldPresented = false 257 | isVoiceFieldPresented = false 258 | } 259 | } 260 | 261 | 262 | // TODO: MessageField Options Extend 263 | 264 | /** 265 | ```swift 266 | struct MyAppCameraButton { 267 | var body: some View { 268 | Button { 269 | MessageField.cameraTapGesturePublisher.send() 270 | } label: { 271 | Image.camera.medium 272 | } 273 | .tint(appearance.tint) 274 | .disabled(isMenuItemPresented) // how... 275 | .frame(width: 36, height: 36) 276 | } 277 | } 278 | 279 | MessageField(sendAction: ...) { 280 | MessageTextField() 281 | .fieldbar { 282 | ItemGroup(placement: .leading) { 283 | MyAppCameraButton() 284 | } 285 | 286 | FieldItemGroup(placement: .trailing) { 287 | VoiceButton() 288 | 289 | EmojiButton() 290 | } 291 | } 292 | } 293 | 294 | 295 | ``` 296 | */ 297 | 298 | extension MessageField { 299 | public enum Style { 300 | case fieldOption 301 | } 302 | 303 | public enum Placement { 304 | case leading 305 | case trailing 306 | } 307 | 308 | public struct FieldOptionModifier: ViewModifier { 309 | let placement: MessageField.Placement 310 | let label: () -> Label 311 | 312 | public func body(content: Content) -> some View { 313 | HStack(alignment: .bottom) { 314 | if placement == .leading { 315 | label() 316 | } 317 | 318 | content 319 | 320 | if placement == .trailing { 321 | label() 322 | } 323 | } 324 | } 325 | 326 | init(_ placement: MessageField.Placement, @ViewBuilder label: @escaping () -> Label) { 327 | self.placement = placement 328 | self.label = label 329 | } 330 | } 331 | } 332 | 333 | extension MessageField { 334 | public func fieldOption(_ placement: MessageField.Placement, @ViewBuilder label: @escaping () -> Label) -> some View { 335 | return AnyView( 336 | modifier( 337 | MessageField.FieldOptionModifier( 338 | placement, 339 | label: label 340 | ) 341 | ) 342 | ) 343 | } 344 | } 345 | -------------------------------------------------------------------------------- /Sources/ChatUI/ChatInChannel/MessageField/MessageTextField.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MessageTextField.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/02/09. 6 | // 7 | 8 | import UIKit 9 | import SwiftUI 10 | 11 | struct MessageTextField: UIViewRepresentable { 12 | @Binding var text: String 13 | @Binding var height: CGFloat 14 | 15 | @State private var isEditing: Bool = false 16 | let placeholder: String = String.MessageField.placeholder 17 | let characterLimit: Int? 18 | 19 | func makeUIView(context: UIViewRepresentableContext) -> UITextView { 20 | let view = UITextView() 21 | view.backgroundColor = .clear 22 | view.font = UIFont.preferredFont(forTextStyle: .subheadline) 23 | view.text = placeholder 24 | view.textColor = UIColor.tertiaryLabel 25 | view.delegate = context.coordinator 26 | view.isEditable = true 27 | view.isUserInteractionEnabled = true 28 | view.isScrollEnabled = true 29 | return view 30 | } 31 | 32 | func updateUIView(_ uiView: UITextView, context: UIViewRepresentableContext) { 33 | if text.isEmpty { 34 | uiView.text = self.isEditing ? "" : placeholder 35 | uiView.textColor = self.isEditing ? UIColor.label : UIColor.tertiaryLabel 36 | } else { 37 | uiView.textColor = UIColor.label 38 | } 39 | 40 | DispatchQueue.main.async { 41 | self.height = uiView.contentSize.height 42 | uiView.textContainerInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) 43 | } 44 | } 45 | 46 | func makeCoordinator() -> Coordinator { 47 | MessageTextField.Coordinator(parent: self) 48 | } 49 | 50 | class Coordinator: NSObject, UITextViewDelegate { 51 | var parent: MessageTextField 52 | 53 | init(parent: MessageTextField) { 54 | self.parent = parent 55 | } 56 | 57 | func textViewDidBeginEditing(_ textView: UITextView) { 58 | DispatchQueue.main.async { 59 | textView.text = self.parent.text 60 | self.parent.isEditing = true 61 | } 62 | } 63 | 64 | func textViewDidEndEditing(_ textView: UITextView) { 65 | DispatchQueue.main.async { 66 | self.parent.isEditing = false 67 | } 68 | } 69 | 70 | func textViewDidChange(_ textView: UITextView) { 71 | if let characterLimit = parent.characterLimit, textView.text.count > characterLimit { 72 | let start = textView.text.index(textView.text.startIndex, offsetBy: 0) 73 | let end = textView.text.index(textView.text.startIndex, offsetBy: 300) 74 | textView.text = String(textView.text[start..() 58 | .sink( 59 | receiveCompletion: { _ in 60 | let style = MessageStyle 61 | .voice(data) 62 | sendMessagePublisher.send(style) 63 | }, 64 | receiveValue: { _ in } 65 | ) 66 | isPresented = false 67 | } 68 | } 69 | } 70 | 71 | func cancel() { 72 | dataModel.cancelRecording() 73 | isPresented = false 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Sources/ChatUI/ChatInChannel/MessageField/NextMessageField.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NextMessageField.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/03/28. 6 | // 7 | 8 | import SwiftUI 9 | 10 | // TODO: Provide pre-implemented send button 11 | 12 | /** 13 | The view for sending messages. 14 | 15 | When creating a ``NextMessageField``, you can provide an action for how to handle a new ``MessageStyle`` information in the `onSend` parameter. ``MessageStyle`` contains different types of messages, such as text, media (photo, video, document, contact) and voice. you can also provide a message-sending button by using ``sendMessagePublisher`` in the `rightLabel`. ``sendMessagePublisher`` will invoke `onSend` handler. 16 | 17 | ```swift 18 | @State private var text: String = "" 19 | 20 | NextMessageField(text) { messageStyle in 21 | guard !text.isEmpty else { return } 22 | viewModel.sendMessage($0) 23 | text = "" 24 | } rightLabel: { 25 | Button { 26 | // send message by using `sendMessagePublisher`. This will invoke `onSend` handler. 27 | sendMessagePublisher.send(.text(text)) 28 | } label: { 29 | // send button icon 30 | Image.send.medium 31 | } 32 | .frame(width: 36, height: 36) 33 | } 34 | ``` 35 | 36 | To add some button on the left of the text field, 37 | ```swift 38 | NextMessageField(text) { messageStyle in 39 | ... 40 | } leftLabel: { 41 | HStack { 42 | Button(aciton: showCamera) { 43 | Image.camera.medium 44 | } 45 | .frame(width: 36, height: 36) 46 | 47 | Button(action: showLibrary) { 48 | Image.photoLibrary.medium 49 | } 50 | .frame(width: 36, height: 36) 51 | } 52 | ``` 53 | 54 | To add some button on the right of the text field, 55 | ```swift 56 | NextMessageField(text) { messageStyle in 57 | ... 58 | } rightLabel: { 59 | HStack { 60 | Button(aciton: { sendMessagePublisher.send(.text(text)) }) { 61 | Image.send.medium 62 | } 63 | .frame(width: 36, height: 36) 64 | } 65 | } 66 | ``` 67 | 68 | To publish a new message, you can create a new `MessageStyle` object and send it using `send(_:)`. 69 | 70 | ```swift 71 | // Create `MessageStyle` object 72 | let style = MessageStyle.text("{TEXT}") 73 | // Publish the created style object via `send(_:)` 74 | sendMessagePublisher.send(style) 75 | ``` 76 | 77 | You can make other views to subscribe to `sendMessagePublisher` to handle new messages. 78 | 79 | ```swift 80 | .onReceive(sendMessagePublisher) { messageStyle in 81 | // Handle `messageStyle` here (e.g., sending message with the style) 82 | } 83 | ``` 84 | */ 85 | public struct NextMessageField: View { 86 | @EnvironmentObject private var configuration: ChatConfiguration 87 | 88 | @Environment(\.appearance) var appearance 89 | 90 | @Binding public var text: String 91 | 92 | @FocusState private var isTextFieldFocused: Bool 93 | @State private var textFieldHeight: CGFloat = 20 94 | 95 | let leftLabel: (() -> LeftLabel)? 96 | let rightLabel: (() -> RightLabel)? 97 | let showsSendButtonAlways: Bool = false 98 | let characterLimit: Int? 99 | let onSend: (_ messageStyle: MessageStyle) -> () 100 | 101 | public var body: some View { 102 | HStack(alignment: .bottom) { 103 | if let leftLabel { 104 | leftLabel() 105 | .tint(appearance.tint) 106 | } 107 | 108 | // TextField 109 | HStack(alignment: .bottom) { 110 | MessageTextField(text: $text, height: $textFieldHeight, characterLimit: characterLimit) 111 | .frame(height: textFieldHeight < 90 ? textFieldHeight : 90) 112 | .padding(.leading, 9) 113 | .padding(.trailing, 4) 114 | .focused($isTextFieldFocused) 115 | } 116 | .padding(6) 117 | .background { 118 | appearance.secondaryBackground 119 | .clipShape(RoundedRectangle(cornerRadius: 18)) 120 | } 121 | 122 | if let rightLabel { 123 | rightLabel() 124 | .tint(appearance.tint) 125 | } 126 | } 127 | .padding(16) 128 | .onReceive(sendMessagePublisher) { messageStyle in 129 | onSend(messageStyle) 130 | } 131 | } 132 | 133 | public init( 134 | _ text: Binding, 135 | characterLimit: Int? = nil, 136 | onSend: @escaping (_ messageStyle: MessageStyle) -> (), 137 | @ViewBuilder leftLabel: @escaping () -> LeftLabel, 138 | @ViewBuilder rightLabel: @escaping () -> RightLabel 139 | ) { 140 | self._text = text 141 | self.characterLimit = characterLimit 142 | self.onSend = onSend 143 | self.leftLabel = leftLabel 144 | self.rightLabel = rightLabel 145 | } 146 | 147 | public init( 148 | _ text: Binding, 149 | characterLimit: Int? = nil, 150 | onSend: @escaping (_ messageStyle: MessageStyle) -> (), 151 | @ViewBuilder rightLabel: @escaping () -> RightLabel 152 | ) where LeftLabel == EmptyView { 153 | self._text = text 154 | self.characterLimit = characterLimit 155 | self.onSend = onSend 156 | self.leftLabel = nil 157 | self.rightLabel = rightLabel 158 | } 159 | 160 | public init( 161 | _ text: Binding, 162 | characterLimit: Int? = nil, 163 | onSend: @escaping (_ messageStyle: MessageStyle) -> (), 164 | @ViewBuilder leftLabel: @escaping () -> LeftLabel 165 | ) where RightLabel == EmptyView { 166 | self._text = text 167 | self.characterLimit = characterLimit 168 | self.onSend = onSend 169 | self.leftLabel = leftLabel 170 | self.rightLabel = nil 171 | } 172 | 173 | public init( 174 | _ text: Binding, 175 | characterLimit: Int? = nil, 176 | onSend: @escaping (_ messageStyle: MessageStyle) -> () 177 | ) where LeftLabel == EmptyView, RightLabel == EmptyView { 178 | self._text = text 179 | self.characterLimit = characterLimit 180 | self.onSend = onSend 181 | self.leftLabel = nil 182 | self.rightLabel = nil 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /Sources/ChatUI/ChatInChannel/MessageMenu/MessageMenu.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MessageMenu.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/03/06. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// The menu for the message 11 | public struct MessageMenu: View { 12 | @Environment(\.appearance) var appearance 13 | @ViewBuilder let content: () -> Content 14 | 15 | public var body: some View { 16 | VStack(spacing: 0) { 17 | content() 18 | } 19 | .frame(width: 240) 20 | .background(appearance.remoteMessageBackground) 21 | .cornerRadius(14) 22 | } 23 | 24 | public init(@ViewBuilder content: @escaping () -> Content) { 25 | self.content = content 26 | } 27 | } 28 | 29 | public struct MessageMenuButtonStyle: ButtonStyle { 30 | @Environment(\.appearance) var appearance 31 | let symbol: String 32 | 33 | public func makeBody(configuration: Configuration) -> some View { 34 | HStack { 35 | configuration.label 36 | .frame(height: 44) 37 | 38 | Spacer() 39 | Image(systemName: symbol) 40 | } 41 | .padding(.horizontal, 16) 42 | .foregroundColor(appearance.primary) 43 | .background(configuration.isPressed ? appearance.secondaryBackground : Color.clear) 44 | } 45 | 46 | public init(symbol: String) { 47 | self.symbol = symbol 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Sources/ChatUI/ChatInChannel/MessageReaction/ReactionEffectView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ReactionEffectView.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/03/05. 6 | // 7 | 8 | import SwiftUI 9 | 10 | // - INFORMATION: [Reference - Youtube](https://www.youtube.com/watch?v=S7hhHc9FgnY) 11 | public struct ReactionEffectView: View { 12 | @Environment(\.appearance) var appearance 13 | 14 | @State var animationValues: [Bool] = Array(repeating: false, count: 6) 15 | 16 | var item: String 17 | var effectTint: Color 18 | 19 | public var body: some View { 20 | ZStack { 21 | Text(item) 22 | .font(appearance.title) 23 | .padding(6) 24 | .background { 25 | effectTint 26 | .clipShape(Circle()) 27 | } 28 | .scaleEffect(animationValues[2] ? 1 : 0) 29 | .overlay { 30 | Circle() 31 | .stroke(effectTint, lineWidth: animationValues[1] ? 0 : 100) 32 | .clipShape(Circle()) 33 | .scaleEffect(animationValues[0] ? 1.6 : 0.01) 34 | } 35 | // MARK: Random Circles 36 | .overlay { 37 | ZStack { 38 | ForEach(1...20, id: \.self) { index in 39 | Circle() 40 | .fill(effectTint) 41 | .frame(width: .random(in: 3...5), height: .random(in: 3...5)) 42 | .offset(x: .random(in: -5...5), y: .random(in: -5...5)) 43 | .offset(x: animationValues[3] ? 45 : 10) 44 | .rotationEffect(.init(degrees: Double(index) * 18.0)) 45 | .scaleEffect(animationValues[2] ? 1 : 0.01) 46 | .opacity(animationValues[4] ? 0 : 1) 47 | } 48 | } 49 | } 50 | } 51 | .onAppear { 52 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { 53 | withAnimation(.easeInOut(duration: 0.35)) { 54 | animationValues[0] = true 55 | } 56 | withAnimation(.easeInOut(duration: 0.45).delay(0.06)) { 57 | animationValues[1] = true 58 | } 59 | withAnimation(.easeInOut(duration: 0.35).delay(0.3)) { 60 | animationValues[2] = true 61 | } 62 | withAnimation(.easeInOut(duration: 0.35).delay(0.4)) { 63 | animationValues[3] = true 64 | } 65 | withAnimation(.easeInOut(duration: 0.55).delay(0.55)) { 66 | animationValues[4] = true 67 | } 68 | } 69 | } 70 | } 71 | 72 | public init(item: String, effectTint: Color) { 73 | self.item = item 74 | self.effectTint = effectTint 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /Sources/ChatUI/ChatInChannel/MessageReaction/ReactionSelector.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ReactionSelector.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/03/05. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public struct ReactionSelector: View { 11 | @Environment(\.appearance) var appearance 12 | 13 | @Binding var isPresented: Bool 14 | 15 | let message: MessageType 16 | let items: [String] 17 | let onReaction: (String) -> () 18 | 19 | 20 | // update the count based on your reaction item array size 21 | @State var effectItem: [Bool] = Array(repeating: false, count: 5) 22 | @State var isEffectAnimated: Bool = false 23 | 24 | public var body: some View { 25 | HStack(spacing: 12) { 26 | ForEach(Array(items.enumerated()), id: \.element) { index, item in 27 | Text(item) 28 | .font(appearance.title) 29 | .scaleEffect(effectItem[index] ? 1 : 0.1) 30 | .onAppear { 31 | // animate 32 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { 33 | withAnimation(.easeInOut.delay(Double(index) * 0.1)) { 34 | effectItem[index] = true 35 | } 36 | } 37 | } 38 | .onTapGesture { 39 | onReaction(items[index]) 40 | } 41 | } 42 | } 43 | .padding(.horizontal, 15) 44 | .padding(.vertical, 8) 45 | .background { 46 | Capsule() 47 | .fill(appearance.secondaryBackground) 48 | .mask { 49 | Capsule() 50 | .scaleEffect(isEffectAnimated ? 1 : 0.1, anchor: .leading) 51 | } 52 | } 53 | .onAppear { 54 | withAnimation { 55 | isEffectAnimated = true 56 | } 57 | } 58 | .onChange(of: isPresented) { newValue in 59 | if !newValue { 60 | withAnimation(.easeInOut(duration: 0.2).delay(0.15)) { 61 | isEffectAnimated = true 62 | } 63 | 64 | for index in items.indices { 65 | withAnimation(.easeInOut) { 66 | effectItem[index] = false 67 | } 68 | } 69 | } 70 | } 71 | } 72 | 73 | init(isPresented: Binding, message: MessageType, items: [String], onReaction: @escaping (String) -> Void) { 74 | self._isPresented = isPresented 75 | self.message = message 76 | self.items = items 77 | self.onReaction = onReaction 78 | self.effectItem = effectItem 79 | self.isEffectAnimated = isEffectAnimated 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /Sources/ChatUI/ChatInChannel/MessageRow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MessageRow.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/02/08. 6 | // 7 | 8 | import SwiftUI 9 | import Combine 10 | 11 | public struct MessageRow: View { 12 | 13 | @EnvironmentObject var configuration: ChatConfiguration 14 | @Environment(\.colorScheme) var colorScheme 15 | @Environment(\.appearance) var appearance 16 | 17 | let message: M 18 | let showsUsername: Bool 19 | let showsDate: Bool 20 | let showsProfileImage: Bool 21 | let showsReadReceiptStatus: Bool 22 | let lineLimit: Int? 23 | 24 | @State private var isSelected: Bool = false 25 | 26 | var isMyMessage: Bool { 27 | message.sender.id == configuration.userID 28 | } 29 | 30 | public var body: some View { 31 | VStack(spacing: 0) { 32 | HStack(alignment: .bottom, spacing: 4) { 33 | if isMyMessage { 34 | Spacer() 35 | 36 | if showsDate { 37 | Text( 38 | Date(timeIntervalSince1970: message.sentAt), 39 | formatter: formatter 40 | ) 41 | .messageStyle(.date) 42 | } 43 | } else { 44 | if showsProfileImage { 45 | AsyncImage(url: message.sender.imageURL) { image in 46 | image 47 | .resizable() 48 | .messageStyle(.senderProfile) 49 | } placeholder: { 50 | Image(systemName: "person.crop.circle.fill") 51 | .resizable() 52 | .messageStyle(.senderProfile) 53 | .foregroundColor(appearance.secondary) 54 | } 55 | } 56 | } 57 | 58 | VStack(alignment: isMyMessage ? .trailing : .leading, spacing: 2) { 59 | if showsUsername, !isMyMessage { 60 | Text(message.sender.username) 61 | .messageStyle(.senderName) 62 | .padding(.horizontal, 8) 63 | } 64 | 65 | if let reactableMessage = message as? (any MessageReactable) { 66 | switch reactableMessage.reaction { 67 | case .none: 68 | EmptyView() 69 | case .reacted(let reactionItem): 70 | ReactionEffectView( 71 | item: reactionItem, 72 | effectTint: isMyMessage 73 | ? appearance.remoteMessageBackground 74 | : appearance.localMessageBackground 75 | ) 76 | .offset(x: isMyMessage ? 15 : -15) 77 | .padding(.bottom, -25) 78 | .zIndex(1) 79 | .opacity(isSelected ? 0 : 1) 80 | } 81 | 82 | } 83 | 84 | // MARK: Message bubble 85 | MessageView(style: message.style, isMyMessage: isMyMessage, lineLimit: lineLimit) 86 | .zIndex(0) 87 | .onTapGesture { } 88 | .onLongPressGesture { 89 | withAnimation(.easeInOut) { 90 | let _ = Empty() 91 | .sink { _ in 92 | highlightMessagePublisher.send(message) 93 | } receiveValue: { _ in } 94 | } 95 | } 96 | } 97 | 98 | if !isMyMessage { 99 | if showsDate { 100 | Text( 101 | Date(timeIntervalSince1970: message.sentAt), 102 | formatter: formatter 103 | ) 104 | .messageStyle(.date) 105 | } 106 | 107 | Spacer() 108 | } 109 | } 110 | 111 | if showsReadReceiptStatus, isMyMessage { 112 | HStack { 113 | Spacer() 114 | 115 | switch message.readReceipt { 116 | case .sending: 117 | appearance.images.sending(colorScheme).xSmall2 118 | .clipShape(Circle()) 119 | .foregroundColor(appearance.secondary) 120 | .padding(.top, 4) 121 | case .failed: 122 | appearance.images.failed(colorScheme).xSmall2 123 | .clipShape(Circle()) 124 | .foregroundColor(appearance.error) 125 | .padding(.top, 4) 126 | case .sent: 127 | appearance.images.sent(colorScheme).xSmall2 128 | .clipShape(Circle()) 129 | .foregroundColor(appearance.tint) 130 | .padding(.top, 4) 131 | case .delivered: 132 | appearance.images.delivered(colorScheme).xSmall2 133 | .clipShape(Circle()) 134 | .foregroundColor(appearance.tint) 135 | .padding(.top, 4) 136 | default: 137 | EmptyView() 138 | } 139 | } 140 | 141 | } 142 | } 143 | .onReceive(highlightMessagePublisher) { highlightMessage in 144 | isSelected = message.id == highlightMessage?.id 145 | } 146 | } 147 | 148 | public init( 149 | message: M, 150 | showsUsername: Bool = true, 151 | showsDate: Bool = true, 152 | showsProfileImage: Bool = true, 153 | showsReadReceiptStatus: Bool = true, 154 | lineLimit: Int? = nil 155 | ) { 156 | self.message = message 157 | self.showsUsername = showsUsername 158 | self.showsDate = showsDate 159 | self.showsProfileImage = showsProfileImage 160 | self.showsReadReceiptStatus = showsReadReceiptStatus 161 | self.lineLimit = lineLimit 162 | } 163 | 164 | var formatter: DateFormatter { 165 | let formatter = DateFormatter() 166 | formatter.dateFormat = appearance.messageTimeFormat 167 | return formatter 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /Sources/ChatUI/ChatInChannel/MessageSearchBar.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MessageSearchBar.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/02/15. 6 | // 7 | 8 | import SwiftUI 9 | 10 | // TODO: Not Supported yet 11 | struct MessageSearchBar: View { 12 | @State private var searchText: String = "" 13 | 14 | var body: some View { 15 | HStack { 16 | Image(systemName: "magnifyingglass") 17 | .xSmall 18 | .foregroundColor(.secondary) 19 | 20 | TextField("Search messages...", text: $searchText) 21 | .textFieldStyle(.plain) 22 | } 23 | .padding(.horizontal, 8) 24 | .frame(height: 36) 25 | .background { 26 | Color(.secondarySystemBackground) 27 | .clipShape(RoundedRectangle(cornerRadius: 10)) 28 | } 29 | } 30 | } 31 | 32 | 33 | /** 34 | MessageList {... } 35 | .searchable(.visible) 36 | 37 | // search icon appears on the toolbar (navigation trailing) 38 | // When tap the icon -> search bar appears 39 | // shows all messages include search text 40 | */ 41 | -------------------------------------------------------------------------------- /Sources/ChatUI/ChatInChannel/MessageViews/GiphyStyleView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GiphyStyleView.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/02/09. 6 | // 7 | 8 | import SwiftUI 9 | import GiphyUISDK 10 | 11 | public struct GiphyStyleView: View { 12 | @State private var aspectRatio: CGFloat = 1 13 | let id: String 14 | 15 | public var body: some View { 16 | GiphyMediaView(gifID: id, aspectRatio: $aspectRatio) 17 | .frame(width: 120 * aspectRatio, height: 120) 18 | .clipShape(RoundedRectangle(cornerRadius: 21)) 19 | } 20 | 21 | public init(id: String) { 22 | self.id = id 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/ChatUI/ChatInChannel/MessageViews/LocationStyleView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LocationStyleView.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/02/09. 6 | // 7 | 8 | import SwiftUI 9 | import MapKit 10 | 11 | public struct LocationStyleView: View { 12 | let latitude: Double 13 | let longitude: Double 14 | 15 | var region: MKCoordinateRegion { 16 | MKCoordinateRegion( 17 | center: .init(latitude: latitude, longitude: longitude), 18 | span: .init(latitudeDelta: 0.005, longitudeDelta: 0.005) 19 | ) 20 | } 21 | 22 | public var body: some View { 23 | Map( 24 | coordinateRegion: .constant(region), 25 | interactionModes: [], 26 | annotationItems: [Location(coordinate: .init(latitude: latitude, longitude: longitude))] 27 | ) { location in 28 | MapMarker(coordinate: location.coordinate) 29 | } 30 | .frame(width: 220, height: 120) 31 | .clipShape(RoundedRectangle(cornerRadius: 21)) 32 | } 33 | 34 | public init(latitude: Double, longitude: Double) { 35 | self.latitude = latitude 36 | self.longitude = longitude 37 | } 38 | } 39 | 40 | extension LocationStyleView { 41 | struct Location: Identifiable { 42 | var id: String { "\(coordinate.latitude)-\(coordinate.longitude)"} 43 | let coordinate: CLLocationCoordinate2D 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/ChatUI/ChatInChannel/MessageViews/MessageView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MessageView.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/03/05. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct MessageView: View { 11 | @Environment(\.appearance) var appearance 12 | 13 | let style: MessageStyle 14 | let isMyMessage: Bool 15 | let lineLimit: Int? 16 | 17 | var body: some View { 18 | switch style { 19 | case .text(let text): 20 | let markdown = LocalizedStringKey(text) 21 | Text(markdown) 22 | .tint(isMyMessage ? appearance.prominentLink : appearance.link) 23 | .messageStyle(isMyMessage ? .localBody(lineLimit) : .remoteBody(lineLimit)) 24 | case .media(let mediaType): 25 | switch mediaType { 26 | case .emoji(let key): 27 | Text(key) 28 | .messageStyle(isMyMessage ? .localBody(lineLimit) : .remoteBody(lineLimit)) 29 | case .gif(let key): 30 | GiphyStyleView(id: key) 31 | case .photo(let data): 32 | PhotoStyleView(data: data) 33 | case .video(let data): 34 | Text("\(data)") 35 | .lineLimit(5) 36 | .messageStyle(isMyMessage ? .localBody(lineLimit) : .remoteBody(lineLimit)) 37 | case .document(let data): 38 | Text("\(data)") 39 | .lineLimit(5) 40 | .messageStyle(isMyMessage ? .localBody(lineLimit) : .remoteBody(lineLimit)) 41 | case .contact(let contact): 42 | let markdown = """ 43 | Name: **\(contact.givenName) \(contact.familyName)** 44 | Phone: \(contact.phoneNumbers) 45 | """ 46 | Text(.init(markdown)) 47 | .messageStyle(isMyMessage ? .localBody(lineLimit) : .remoteBody(lineLimit)) 48 | case .location(let latitude, let longitude): 49 | LocationStyleView( 50 | latitude: latitude, 51 | longitude: longitude 52 | ) 53 | } 54 | case .voice(let data): 55 | VoiceStyleView(data: data) 56 | .messageStyle(isMyMessage ? .localBody(lineLimit) : .remoteBody(lineLimit)) 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Sources/ChatUI/ChatInChannel/MessageViews/PhotoStyleView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PhotoStyleView.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/02/09. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public struct PhotoStyleView: View { 11 | 12 | @Environment(\.colorScheme) var colorScheme 13 | @Environment(\.appearance) var appearance 14 | 15 | let data: Data 16 | var uiImage: UIImage? { 17 | UIImage(data: data) 18 | } 19 | 20 | public var body: some View { 21 | if let uiImage = uiImage { 22 | Image(uiImage: uiImage) 23 | .resizable() 24 | .scaledToFill() 25 | .frame(width: 220, height: 120) 26 | .clipShape(RoundedRectangle(cornerRadius: 21)) 27 | } else { 28 | placeholder 29 | } 30 | } 31 | 32 | var placeholder: some View { 33 | RoundedRectangle(cornerRadius: 21) 34 | .fill(Color(uiColor: .secondarySystemBackground)) 35 | .frame(width: 220, height: 120) 36 | .overlay { 37 | VStack { 38 | appearance.images.downloadFailed(colorScheme).xLarge 39 | 40 | Text(String.Message.failedPhoto) 41 | .font(.footnote.bold()) 42 | } 43 | .foregroundColor(Color.secondary) 44 | } 45 | } 46 | 47 | public init(data: Data) { 48 | self.data = data 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Sources/ChatUI/ChatInChannel/MessageViews/VoiceStyleView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VoiceStyleView.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/02/10. 6 | // 7 | 8 | import AVKit 9 | import SwiftUI 10 | import Combine 11 | 12 | /// Data model to handle `AVAudioPlayerDelegate` 13 | class VoicePlayer: NSObject, ObservableObject, AVAudioPlayerDelegate { 14 | @Published var audioPlayer: AVAudioPlayer? 15 | @Published var isPlaying: Bool = false 16 | @Published var duration: Int = 0 17 | 18 | var timerPublisher: AnyCancellable? 19 | 20 | func setup(with data: Data) { 21 | audioPlayer = try? AVAudioPlayer(data: data) 22 | self.duration = Int(audioPlayer?.duration ?? 0) 23 | audioPlayer?.delegate = self 24 | } 25 | 26 | func play() { 27 | if audioPlayer?.prepareToPlay() == true { 28 | timerPublisher = Timer.publish(every: 0.2, on: .main, in: .common).autoconnect() 29 | .sink(receiveValue: { value in 30 | if let audioPlayer = self.audioPlayer { 31 | self.duration = Int(audioPlayer.currentTime) 32 | } 33 | }) 34 | audioPlayer?.play() 35 | isPlaying = true 36 | } 37 | } 38 | 39 | func stop() { 40 | timerPublisher?.cancel() 41 | timerPublisher = nil 42 | audioPlayer?.stop() 43 | isPlaying = false 44 | self.duration = Int(audioPlayer?.duration ?? 0) 45 | } 46 | 47 | func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { 48 | isPlaying = !flag 49 | } 50 | } 51 | 52 | public struct VoiceStyleView: View { 53 | @Environment(\.colorScheme) var colorScheme 54 | @Environment(\.appearance) var appearance 55 | @StateObject private var dataModel = VoicePlayer() 56 | 57 | let data: Data 58 | 59 | public var body: some View { 60 | HStack { 61 | Button(action: controlAudioPlayer) { 62 | Group { 63 | if dataModel.isPlaying { 64 | appearance.images.pause(colorScheme).medium 65 | } else { 66 | appearance.images.play(colorScheme).medium 67 | } 68 | } 69 | .foregroundColor(.white) 70 | } 71 | 72 | Text( 73 | String( 74 | format: "%02i:%02i", 75 | dataModel.duration / 60, 76 | dataModel.duration % 60 77 | ) 78 | ) 79 | .font(.footnote) 80 | .fontWeight(.semibold) 81 | .foregroundColor(.white) 82 | } 83 | .onAppear { 84 | dataModel.setup(with: data) 85 | } 86 | } 87 | 88 | public init(data: Data) { 89 | self.data = data 90 | } 91 | 92 | func controlAudioPlayer() { 93 | if dataModel.isPlaying { 94 | dataModel.stop() 95 | } else { 96 | dataModel.play() 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /Sources/ChatUI/ChatInChannel/ScrollButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ScrollButton.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/02/09. 6 | // 7 | 8 | import SwiftUI 9 | import Combine 10 | 11 | public struct ScrollButton: View { 12 | @Environment(\.colorScheme) var colorScheme 13 | @Environment(\.appearance) var appearance 14 | 15 | public var body: some View { 16 | Button(action: scrollToBotton) { 17 | appearance.images.directionDown(colorScheme).small 18 | .foregroundColor(appearance.tint) 19 | .padding(8) 20 | .background { 21 | Color(.systemBackground) 22 | .clipShape(Circle()) 23 | .shadow(radius: 6) 24 | } 25 | } 26 | } 27 | 28 | func scrollToBotton() { 29 | let _ = Empty() 30 | .sink( 31 | receiveCompletion: { _ in 32 | scrollDownPublisher.send(()) 33 | }, 34 | receiveValue: { _ in } 35 | ) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Sources/ChatUI/ChatUI.swift: -------------------------------------------------------------------------------- 1 | public struct ChatUI { 2 | public private(set) var text = "Hello, World!" 3 | 4 | public init() { 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Sources/ChatUI/Enums/MediaType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MediaType.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/02/08. 6 | // 7 | 8 | import Foundation 9 | import Contacts 10 | 11 | /// The types of the media. 12 | public enum MediaType: Hashable { 13 | /// Emoji. 14 | case emoji(_ id: String) 15 | /// GIF 16 | case gif(_ id: String) 17 | /// Taken photo with the *Camera* or chosen photo from the *Gallery*. 18 | case photo(_ data: Data) 19 | /// Taken video with the *Camera* or chosen video from the *Phone*. 20 | case video(_ date: Data) 21 | /// Document file from the *Phone* 22 | case document(_ data: Data) 23 | /// Contact's information saved in the phone's *address book*. 24 | case contact(_ contact: CNContact) 25 | /// The user location or a nearby place. 26 | case location(_ latitude: Double, _ longitude: Double) 27 | } 28 | -------------------------------------------------------------------------------- /Sources/ChatUI/Enums/MessageItemPlacement.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MessageItemPlacement.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/02/08. 6 | // 7 | 8 | import Foundation 9 | 10 | // TODO: Not Support yet 11 | /// The enumeration for the cases that how to aligns the messages. 12 | enum MessageItemPlacement: Int, Hashable { 13 | /// Aligns all messages to the left side. 14 | case left 15 | /// Aligns all messages to the right side. 16 | case right 17 | /// Aligns messages to the both sides by following rules: 18 | /// - If the message is sent by the remote user, aligns to the left side. 19 | /// - If the message is sent by the local user, aligns to the right side. 20 | case both 21 | } 22 | -------------------------------------------------------------------------------- /Sources/ChatUI/Enums/MessageOption.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MessageOption.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/02/08. 6 | // 7 | 8 | import Foundation 9 | 10 | /// The option for how to send message such as camera, mic and so on. 11 | public enum MessageOption: Int, Hashable, CaseIterable { 12 | public static var all: [MessageOption] { MessageOption.allCases } 13 | case camera 14 | case photoLibrary 15 | case mic 16 | case giphy 17 | case location 18 | case menu 19 | } 20 | -------------------------------------------------------------------------------- /Sources/ChatUI/Enums/MessageStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MessageStyle.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/02/08. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | /// The Styles of the message. 12 | /// 13 | /// 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 14 | public enum MessageStyle: Hashable { 15 | /// General text message 16 | case text(_ text: String) 17 | /// The media message such as photo, video, document, contact and so on. 18 | case media(_ mediaType: MediaType) 19 | /// The voice message which is recorded by microphone in the chat screen. 20 | case voice(_ data: Data) 21 | } 22 | -------------------------------------------------------------------------------- /Sources/ChatUI/Enums/ReadReceipt.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ReadReceipt.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/02/08. 6 | // 7 | 8 | import Foundation 9 | 10 | /// The enumeration that represents the read receipt status of the message. 11 | public enum ReadReceipt: Int, Codable, Hashable { 12 | /// The message is sending 13 | case sending 14 | /// The message was failed sending 15 | case failed 16 | /// The message was successfully sent. 17 | case sent 18 | /// The message has been delivered to your recipient's phone or linked devices, but the recipient hasn’t seen it. 19 | case delivered 20 | /// The recipient has read your message. 21 | /// - The recipient has read your message or seen your picture, audio file, or video 22 | /// - The recipient has seen your voice message, but hasn’t played it. 23 | case seen 24 | /// The recipient has played your voice message. 25 | case played 26 | } 27 | -------------------------------------------------------------------------------- /Sources/ChatUI/Enums/TypingIndicatorPlacement.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TypingIndicatorPlacement.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/02/08. 6 | // 7 | 8 | import Foundation 9 | 10 | // TODO: Not Supported yet 11 | enum TypingIndicatorPlacement { 12 | /// Places the typing indicator in the navigation bar. 13 | case navigationBar 14 | /// Places the typing indicator in the message field section. 15 | case messageField 16 | } 17 | -------------------------------------------------------------------------------- /Sources/ChatUI/Essentials/ChatConfiguration.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatConfiguration.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/02/09. 6 | // 7 | 8 | import SwiftUI 9 | import GiphyUISDK 10 | 11 | /** 12 | An object representing the configuration settings for a chat. The settings include the *user ID* and an optional *Giphy API key*. 13 | 14 | Use this object to configure the chat UI before starting a chat session. 15 | To create a ``ChatConfiguration`` object, call its initializer with the required user ID and an optional Giphy API key. 16 | 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. 17 | After creating a ``ChatConfiguration`` object, pass it to the ``ChatUI`` views as an environment object. 18 | 19 | - 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. 20 | 21 | **Example usage:** 22 | 23 | ```swift 24 | @StateObject var config = ChatConfiguration(userID: "user123", giphyKey: "your_giphy_api_key") 25 | 26 | var body: some View { 27 | ChatView() 28 | .environmentObject(config) 29 | } 30 | ``` 31 | - 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. 32 | */ 33 | open class ChatConfiguration: ObservableObject { 34 | 35 | /// User ID for chat 36 | public let userID: String 37 | /// Giphy API key 38 | public let giphyKey: String? 39 | /// Config for setting the giphyPicker appearance 40 | public let giphyConfig: GiphyConfiguration 41 | 42 | /** 43 | Initializes a new ``ChatConfiguration`` object with the specified *user ID* and *Giphy API key* (optional). 44 | 45 | - Parameters: 46 | - userID: A unique identifier for the user. 47 | - giphyKey: An optional Giphy API key. If provided, enables Giphy integration. 48 | - giphyConfig: An optional Giphy appearance config 49 | */ 50 | public init(userID: String, giphyKey: String? = nil, giphyConfig: GiphyConfiguration = GiphyConfiguration()) { 51 | self.userID = userID 52 | self.giphyKey = giphyKey 53 | self.giphyConfig = giphyConfig 54 | 55 | // Giphy 56 | if let giphyKey = giphyKey { 57 | Giphy.configure(apiKey: giphyKey) 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Sources/ChatUI/Essentials/GiphyConfiguration.swift: -------------------------------------------------------------------------------- 1 | import GiphyUISDK 2 | 3 | public struct GiphyConfiguration { 4 | 5 | public let dimBackground: Bool 6 | public let showConfirmationScreen: Bool 7 | public let shouldLocalizeSearch: Bool 8 | public let mediaTypeConfig: [GiphyUISDK.GPHContentType] 9 | public let presentationDetents: CGFloat 10 | 11 | public init( 12 | dimBackground: Bool = false, 13 | showConfirmationScreen: Bool = false, 14 | shouldLocalizeSearch: Bool = false, 15 | mediaTypeConfig: [GiphyUISDK.GPHContentType] = [.gifs, .stickers, .recents], 16 | presentationDetents: CGFloat = 0.9 17 | ) { 18 | self.dimBackground = dimBackground 19 | self.showConfirmationScreen = showConfirmationScreen 20 | self.shouldLocalizeSearch = shouldLocalizeSearch 21 | self.mediaTypeConfig = mediaTypeConfig 22 | self.presentationDetents = presentationDetents 23 | } 24 | } -------------------------------------------------------------------------------- /Sources/ChatUI/PreferenceKeys/BoundsPreferenceKey.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BoundsPreferenceKey.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/03/05. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// The preference key to detect anchors of the bounds as `CGRect` 11 | public struct BoundsPreferenceKey: PreferenceKey { 12 | public internal(set) static var defaultValue: [String: Anchor] = [:] 13 | 14 | public static func reduce(value: inout [String : Anchor], nextValue: () -> [String : Anchor]) { 15 | value.merge(nextValue()) { $1 } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/ChatUI/PreferenceKeys/ScrollViewOffsetPreferenceKey.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ScrollViewOffsetPreferenceKey.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/02/08. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// The preference key to detect scroll view offeset 11 | public struct ScrollViewOffsetPreferenceKey: PreferenceKey { 12 | public internal(set) static var defaultValue: CGFloat? = nil 13 | 14 | public static func reduce(value: inout CGFloat?, nextValue: () -> CGFloat?) { 15 | value = value ?? nextValue() 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/ChatUI/Previews/ChatInChannel/ChannelStack.Previews.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChannelStack.Previews.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/02/09. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ChannelStack_Previews: PreviewProvider { 11 | static var previews: some View { 12 | NavigationView { 13 | Preview() 14 | .environmentObject( 15 | ChatConfiguration( 16 | userID: "andrew_parker", 17 | giphyKey: "wj5tEh9nAwNHVF3ZFavQ0zoaIyt8HZto" 18 | ) 19 | ) 20 | } 21 | } 22 | 23 | struct Preview: View { 24 | @State private var messages: [Message] = [ 25 | Message.message5, 26 | Message.message4, 27 | Message.message2, 28 | Message.message1, 29 | ] 30 | 31 | var body: some View { 32 | ChannelStack(GroupChannel.channel1) { 33 | MessageList(messages) { message in 34 | MessageRow( 35 | message: message, 36 | showsUsername: false 37 | ) 38 | .padding(.top, 12) 39 | } 40 | 41 | MessageField(isMenuItemPresented: .constant(false)) { 42 | messages.insert( 43 | Message( 44 | id: UUID().uuidString, 45 | sender: User.user1, 46 | sentAt: Date().timeIntervalSince1970, 47 | readReceipt: .sending, 48 | style: $0 49 | ), 50 | at: 0 51 | ) 52 | } 53 | } 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Sources/ChatUI/Previews/ChatInChannel/MessageField.Previews.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MessageField.Previews.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/02/08. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct MessageField_Previews: PreviewProvider { 11 | static var previews: some View { 12 | Group { 13 | Preview() 14 | .previewDisplayName("Message Field") 15 | 16 | VStack { 17 | MessageField(options: [.mic]) { _ in } 18 | 19 | MessageField(options: [.camera, .photoLibrary]) { _ in } 20 | 21 | MessageField(options: [.menu]) { _ in } 22 | 23 | MessageField(options: [.giphy]) { _ in } 24 | 25 | MessageField(options: MessageOption.all) { _ in } 26 | 27 | } 28 | .previewDisplayName("Message Field Options") 29 | 30 | MessageField(showsSendButtonAlways: true) { _ in } 31 | .previewDisplayName("Showing Send Button Always") 32 | 33 | MenuItemPreview() 34 | .previewDisplayName("Menu items") 35 | 36 | LocationSelectorPreview() 37 | .previewDisplayName("Location Selector") 38 | 39 | // VoiceField(isPresented: .constant(true)) 40 | // .previewDisplayName("Voice Field") 41 | } 42 | .environmentObject( 43 | ChatConfiguration( 44 | userID: User.user1.id, 45 | giphyKey: "wj5tEh9nAwNHVF3ZFavQ0zoaIyt8HZto" 46 | ) 47 | ) 48 | } 49 | 50 | struct Preview: View { 51 | @State private var pendingMessage: Message? 52 | @State private var isMenuItemPresented: Bool = false 53 | 54 | var body: some View { 55 | VStack { 56 | if let pendingMessage = pendingMessage { 57 | Text(pendingMessage.id) 58 | 59 | Text(String(describing: pendingMessage.style)) 60 | } 61 | 62 | Spacer() 63 | 64 | MessageField(isMenuItemPresented: $isMenuItemPresented) { messageStyle in 65 | pendingMessage = Message( 66 | id: UUID().uuidString, 67 | sender: User.user1, 68 | sentAt: Date().timeIntervalSince1970, 69 | readReceipt: .sending, 70 | style: messageStyle 71 | ) 72 | } 73 | 74 | if isMenuItemPresented { 75 | LocationSelector(isPresented: .constant(true)) 76 | } 77 | } 78 | } 79 | } 80 | 81 | struct MenuItemPreview: View { 82 | @Environment(\.colorScheme) var colorScheme 83 | @Environment(\.appearance) var appearance 84 | 85 | @State private var isMenuItemPresented: Bool = false 86 | 87 | var body: some View { 88 | VStack { 89 | Spacer() 90 | 91 | MessageField( 92 | options: [.menu], 93 | isMenuItemPresented: $isMenuItemPresented 94 | ) { _ in } 95 | 96 | if isMenuItemPresented { 97 | additionalContent 98 | } 99 | } 100 | .alert( 101 | Text("Menu"), 102 | isPresented: $isMenuItemPresented 103 | ) { 104 | Button(role: .destructive) { 105 | // Handle the deletion. 106 | } label: { 107 | Text("Camera") 108 | } 109 | } 110 | } 111 | 112 | var additionalContent: some View { 113 | ScrollView(.horizontal, showsIndicators: false) { 114 | LazyHStack { 115 | Button(action: { }) { 116 | appearance.images.location(colorScheme).large 117 | .foregroundColor(.white) 118 | .padding(14) 119 | .background { 120 | Color(.cyan) 121 | .clipShape(Circle()) 122 | } 123 | } 124 | 125 | Button(action: { }) { 126 | appearance.images.music(colorScheme).large 127 | .foregroundColor(.white) 128 | .padding(14) 129 | .background { 130 | Color(.systemPink) 131 | .clipShape(Circle()) 132 | } 133 | } 134 | } 135 | } 136 | .background { appearance.secondaryBackground } 137 | .frame(height: 124) 138 | .edgesIgnoringSafeArea(.bottom) 139 | } 140 | } 141 | 142 | struct LocationSelectorPreview: View { 143 | var body: some View { 144 | VStack { 145 | Spacer() 146 | 147 | LocationSelector(isPresented: .constant(true)) 148 | } 149 | 150 | } 151 | } 152 | 153 | struct CameraViewPreview: View { 154 | var body: some View { 155 | CameraField(isPresented: .constant(true)) 156 | } 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /Sources/ChatUI/Previews/ChatInChannel/MessageList.Previews.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MessageList.Previews.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/02/08. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct MessageList_Previews: PreviewProvider { 11 | static var previews: some View { 12 | Group { 13 | NavigationView { 14 | Preview() 15 | } 16 | .previewDisplayName("With Message Field and Navigation") 17 | 18 | MessageList([Message.message1, Message.message2, Message.message3]) { message in 19 | Menu { 20 | Button { 21 | // Add this item to a list of favorites. 22 | } label: { 23 | Label("Add to Favorites", systemImage: "heart") 24 | } 25 | Button { 26 | // Open Maps and center it on this item. 27 | } label: { 28 | Label("Show in Maps", systemImage: "mappin") 29 | } 30 | } label: { 31 | MessageRow(message: message) 32 | .multilineTextAlignment(.leading) 33 | } 34 | } 35 | .previewDisplayName("Message List") 36 | 37 | MessageList( 38 | [Message.message1, Message.message2, Message.message3], 39 | reactionItems: ["❤️", "👍", "👎", "😆", "🎉"] 40 | ) { message in 41 | MessageRow(message: message, showsUsername: false, showsProfileImage: false) 42 | .padding(.top, 12) 43 | } menuContent: { highlightMessage in 44 | MessageMenu { 45 | Button("Copy", action: {}) 46 | .buttonStyle(MessageMenuButtonStyle(symbol: "doc.on.doc")) 47 | 48 | Divider() 49 | Button("Reply", action: {}) 50 | .buttonStyle(MessageMenuButtonStyle(symbol: "arrowshape.turn.up.right")) 51 | Divider() 52 | Button("Delete", action: {}) 53 | .buttonStyle(MessageMenuButtonStyle(symbol: "trash")) 54 | } 55 | .padding(.top, 12) 56 | } 57 | .onReceive(messageReactionPublisher) { (item, messageID) in 58 | print(item) 59 | } 60 | .previewDisplayName("Message Menu") 61 | 62 | MessageList([Message.message1, Message.message2, Message.message3], showsDate: true) { message in 63 | MessageRow(message: message, showsUsername: false, showsProfileImage: false) 64 | .padding(.top, 12) 65 | } 66 | .previewDisplayName("Message Date") 67 | } 68 | .environmentObject( 69 | ChatConfiguration( 70 | userID: User.user1.id, 71 | giphyKey: "wj5tEh9nAwNHVF3ZFavQ0zoaIyt8HZto" 72 | ) 73 | ) 74 | } 75 | 76 | struct Preview: View { 77 | @Environment(\.appearance) var appearance 78 | 79 | @State private var messages: [Message] = [ 80 | Message.message1, Message.message2, Message.message3 81 | ] 82 | 83 | var body: some View { 84 | VStack(spacing: 0) { 85 | MessageList(messages) { message in 86 | MessageRow(message: message) 87 | .onTapGesture(count: 1) { 88 | withAnimation { 89 | switch message.readReceipt { 90 | case .failed: 91 | messages.removeAll { $0.id == message.id } 92 | default: 93 | messages.removeAll { $0.id == message.id } 94 | } 95 | } 96 | } 97 | } 98 | 99 | MessageField { 100 | messages.insert( 101 | Message( 102 | id: UUID().uuidString, 103 | sender: User.user1, 104 | sentAt: Date().timeIntervalSince1970, 105 | readReceipt: .sending, 106 | style: $0 107 | ), 108 | at: 0 109 | ) 110 | } 111 | 112 | } 113 | .toolbar { 114 | ToolbarItem(placement: .navigationBarLeading) { 115 | HStack { 116 | AsyncImage(url: User.user1.imageURL) { image in 117 | image 118 | .resizable() 119 | .frame(width: 36, height: 36) 120 | .aspectRatio(contentMode: .fill) 121 | .clipShape(Circle()) 122 | .padding(1) 123 | .background { 124 | appearance.border 125 | .clipShape(Circle()) 126 | } 127 | } placeholder: { 128 | Image(systemName: "person.crop.circle.fill") 129 | .resizable() 130 | .frame(width: 36, height: 36) 131 | .aspectRatio(contentMode: .fill) 132 | .foregroundColor(.secondary) 133 | .clipShape(Circle()) 134 | } 135 | 136 | VStack(alignment: .leading) { 137 | Text(User.user1.username) 138 | .font(.headline) 139 | 140 | Text(User.user1.id) 141 | .font(.footnote) 142 | .foregroundColor(.secondary) 143 | } 144 | } 145 | } 146 | } 147 | } 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /Sources/ChatUI/Previews/ChatInChannel/MessageRow.Previews.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MessageRow.Previews.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/02/08. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct MessageRow_Previews: PreviewProvider { 11 | static var previews: some View { 12 | Group { 13 | // MARK: - Message Rows 14 | ScrollView(showsIndicators: false) { 15 | LazyVStack { 16 | /// **General** 17 | MessageRow(message: Message.message3) 18 | 19 | /// Hide **username** 20 | MessageRow( 21 | message: Message.message3, 22 | showsUsername: false 23 | ) 24 | 25 | /// Hide **date** 26 | MessageRow( 27 | message: Message.message3, 28 | showsUsername: false, 29 | showsDate: false 30 | ) 31 | 32 | /// Hide **profileImage** 33 | MessageRow( 34 | message: Message.message3, 35 | showsUsername: false, 36 | showsDate: false, 37 | showsProfileImage: false 38 | ) 39 | 40 | Divider() 41 | 42 | /// **General** 43 | MessageRow( 44 | message: Message.message7 45 | ) 46 | 47 | /// Hide **username** 48 | MessageRow( 49 | message: Message.message7, 50 | showsUsername: false 51 | ) 52 | 53 | /// Hide **date** 54 | MessageRow( 55 | message: Message.message7, 56 | showsUsername: false, 57 | showsDate: false 58 | ) 59 | 60 | /// Hide **profileImage** 61 | MessageRow( 62 | message: Message.message7, 63 | showsUsername: false, 64 | showsDate: false, 65 | showsProfileImage: false 66 | ) 67 | 68 | /// Hide **read recipt** 69 | MessageRow( 70 | message: Message.message7, 71 | showsUsername: false, 72 | showsDate: false, 73 | showsProfileImage: false, 74 | showsReadReceiptStatus: false 75 | ) 76 | } 77 | } 78 | .previewDisplayName("Message Rows") 79 | 80 | // MARK: - Read Recipt 81 | ScrollView(showsIndicators: false) { 82 | LazyVStack { 83 | /// **Sending Message** 84 | MessageRow( 85 | message: Message.message9, 86 | showsUsername: false 87 | ) 88 | 89 | /// **Failed Message** 90 | MessageRow( 91 | message: Message.message8, 92 | showsUsername: false 93 | ) 94 | 95 | /// **Sent Message** 96 | MessageRow( 97 | message: Message.message6, 98 | showsUsername: false 99 | ) 100 | 101 | /// **Delivered Message** 102 | MessageRow( 103 | message: Message.message7, 104 | showsUsername: false 105 | ) 106 | 107 | /// **Seen Message** 108 | MessageRow( 109 | message: Message.message2 110 | ) 111 | } 112 | } 113 | .previewDisplayName("Read Recipt") 114 | 115 | // MARK: - Message Style 116 | ScrollView(showsIndicators: false) { 117 | LazyVStack { 118 | /// **Text Message** 119 | /// ``MessageStyle/text(_:)`` 120 | MessageRow(message: Message.message2) 121 | 122 | /// **Location Message** 123 | /// ``MessageStyle/media(_:)``, ``MediaType/location(_:_:)`` 124 | MessageRow(message: Message.locationMessage) 125 | 126 | /// **Photo Message** 127 | /// ``MessageStyle/media(_:)``, ``MediaType/photo(_:)`` 128 | MessageRow(message: Message.photoMessage) 129 | 130 | /// **Failed Photo Message** 131 | MessageRow(message: Message.photoFailedMessage) 132 | 133 | /// **GIF Message** 134 | /// ``MessageStyle/media(_:)``, ``MediaType/gif(_:)`` 135 | MessageRow(message: Message.giphyMessage) 136 | } 137 | } 138 | .previewDisplayName("Message Style") 139 | 140 | MessageItemMenuPreview() 141 | .previewDisplayName("Message Menu") 142 | } 143 | .environmentObject(ChatConfiguration( 144 | userID: User.user1.id, 145 | giphyKey: "wj5tEh9nAwNHVF3ZFavQ0zoaIyt8HZto" 146 | )) 147 | .padding(12) 148 | } 149 | 150 | struct MessageItemMenuPreview: View { 151 | @State private var isMessageMenuPresented: Bool = false 152 | var body: some View { 153 | Menu { 154 | Button { 155 | // Add this item to a list of favorites. 156 | } label: { 157 | Label("Add to Favorites", systemImage: "heart") 158 | } 159 | Button { 160 | // Open Maps and center it on this item. 161 | } label: { 162 | Label("Show in Maps", systemImage: "mappin") 163 | } 164 | } label: { 165 | MessageRow(message: Message.message2) 166 | } 167 | } 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /Sources/ChatUI/Previews/ChatInChannel/MessageSearch.Previews.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MessageSearch.Previews.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/02/15. 6 | // 7 | 8 | import SwiftUI 9 | 10 | // TODO: Not Supported yet 11 | struct MessageSearch_Previews: PreviewProvider { 12 | static var previews: some View { 13 | MessageSearchBar() 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/ChatUI/Previews/ChatInChannel/NextMessageField.Previews.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NextMessageField.Previews.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/08/02. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct NextMessageField_Previews: PreviewProvider { 11 | static var previews: some View { 12 | Preview() 13 | .previewDisplayName("Next Message Field") 14 | } 15 | 16 | struct Preview: View { 17 | 18 | private let appearance = Appearance() 19 | 20 | @Environment(\.colorScheme) var colorScheme 21 | @State private var pendingMessage: Message? 22 | @State private var text: String = "" 23 | 24 | var body: some View { 25 | VStack { 26 | if let pendingMessage = pendingMessage { 27 | Text(pendingMessage.id) 28 | 29 | Text(String(describing: pendingMessage.style)) 30 | } 31 | 32 | Spacer() 33 | 34 | NextMessageField($text) { messageStyle in 35 | pendingMessage = Message( 36 | id: UUID().uuidString, 37 | sender: User.user1, 38 | sentAt: Date().timeIntervalSince1970, 39 | readReceipt: .sending, 40 | style: messageStyle 41 | ) 42 | } leftLabel: { 43 | HStack { 44 | Button(action: {}) { 45 | appearance.images.camera(colorScheme).medium 46 | } 47 | .frame(width: 36, height: 36) 48 | 49 | Button(action: {}) { 50 | appearance.images.photoLibrary(colorScheme).medium 51 | } 52 | .frame(width: 36, height: 36) 53 | 54 | Button(action: {}) { 55 | appearance.images.mic(colorScheme).medium 56 | } 57 | .frame(width: 36, height: 36) 58 | } 59 | } rightLabel: { 60 | Button { 61 | sendMessagePublisher.send(.text(text)) 62 | } label: { 63 | appearance.images.send(colorScheme).medium 64 | } 65 | .frame(width: 36, height: 36) 66 | } 67 | .environment(\.appearance, appearance) 68 | 69 | } 70 | } 71 | } 72 | 73 | } 74 | 75 | -------------------------------------------------------------------------------- /Sources/ChatUI/Previews/TestModels/GroupChannel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GroupChannel.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/02/08. 6 | // 7 | 8 | import Foundation 9 | 10 | struct GroupChannel: ChannelProtocol { 11 | var id: String 12 | var name: String 13 | var imageURL: URL? 14 | var members: [User] 15 | var createdAt: Double 16 | var lastMessage: Message? 17 | } 18 | 19 | extension GroupChannel { 20 | static let channel1 = GroupChannel( 21 | id: User.bluebottle.id, 22 | name: User.bluebottle.username, 23 | imageURL: User.bluebottle.imageURL, 24 | members: [User.user1, User.bluebottle], 25 | createdAt: 1675242048, 26 | lastMessage: nil 27 | ) 28 | 29 | static let new = GroupChannel( 30 | id: UUID().uuidString, 31 | name: User.user2.username, 32 | imageURL: nil, 33 | members: [User.user1, User.user2], 34 | createdAt: 1675860481, 35 | lastMessage: Message.message1 36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /Sources/ChatUI/Previews/TestModels/Message.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Message.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/02/08. 6 | // 7 | 8 | import Foundation 9 | import GiphyUISDK 10 | 11 | struct Message: MessageProtocol, Identifiable { 12 | var id: String 13 | var sender: User 14 | var sentAt: Double 15 | var editedAt: Double? 16 | var readReceipt: ReadReceipt 17 | var style: MessageStyle 18 | } 19 | 20 | extension Message { 21 | static let message1 = Message( 22 | id: UUID().uuidString, 23 | sender: User.bluebottle, 24 | sentAt: 1675331868, 25 | readReceipt: .seen, 26 | 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?") 27 | ) 28 | 29 | static let message2 = Message( 30 | id: UUID().uuidString, 31 | sender: User.user1, 32 | sentAt: 1675342668, 33 | readReceipt: .seen, 34 | 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!") 35 | ) 36 | 37 | static let message3 = Message( 38 | id: UUID().uuidString, 39 | sender: User.starbucks, 40 | sentAt: 1675342668, 41 | readReceipt: .delivered, 42 | 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.") 43 | ) 44 | 45 | static let message4 = Message( 46 | id: UUID().uuidString, 47 | sender: User.bluebottle, 48 | sentAt: 1675334868, 49 | readReceipt: .seen, 50 | style: .text("Do you know what time is it?") 51 | ) 52 | 53 | static let message5 = Message( 54 | id: UUID().uuidString, 55 | sender: User.bluebottle, 56 | sentAt: 1675338868, 57 | readReceipt: .seen, 58 | style: .text("What is the most popular meal in Japan?") 59 | ) 60 | 61 | static let message6 = Message( 62 | id: UUID().uuidString, 63 | sender: User.user1, 64 | sentAt: 1675404869, 65 | readReceipt: .sent, 66 | style: .text("Do you know what time is it?\nRead receipt status: `.sent`") 67 | ) 68 | 69 | static let message7 = Message( 70 | id: UUID().uuidString, 71 | sender: User.user1, 72 | sentAt: 1675408868, 73 | readReceipt: .delivered, 74 | style: .text("**What** is the most *popular* meal in [**Japan**](www.google.com)? ~~sushi~~\nRead receipt status: `.delivered`") 75 | ) 76 | 77 | static let message8 = Message( 78 | id: UUID().uuidString, 79 | sender: User.user1, 80 | sentAt: 1675408868, 81 | readReceipt: .failed, 82 | style: .text("Read receipt status: `.failed`") 83 | ) 84 | 85 | static let message9 = Message( 86 | id: UUID().uuidString, 87 | sender: User.user1, 88 | sentAt: 1675408868, 89 | readReceipt: .sending, 90 | style: .text("Read receipt status: `.sending`") 91 | ) 92 | 93 | static let locationMessage = Message( 94 | id: UUID().uuidString, 95 | sender: User.user1, 96 | sentAt: 1675408868, 97 | readReceipt: .delivered, 98 | style: .media(.location(37.57827, 126.97695)) 99 | ) 100 | 101 | static let photoMessage: Message = { 102 | let data = (try? Data(contentsOf: URL(string: "https://picsum.photos/220")!)) ?? Data() 103 | return Message( 104 | id: UUID().uuidString, 105 | sender: User.user1, 106 | sentAt: 1675408868, 107 | readReceipt: .delivered, 108 | style: .media(.photo(data)) 109 | ) 110 | }() 111 | 112 | static let photoFailedMessage: Message = { 113 | let data = Data() 114 | return Message( 115 | id: UUID().uuidString, 116 | sender: User.user1, 117 | sentAt: 1675408868, 118 | readReceipt: .failed, 119 | style: .media(.photo(data)) 120 | ) 121 | }() 122 | 123 | static let giphyMessage: Message = { 124 | let id = "SU49goxca2V5XzxJPf" 125 | Giphy.configure(apiKey: "wj5tEh9nAwNHVF3ZFavQ0zoaIyt8HZto") 126 | return Message( 127 | id: UUID().uuidString, 128 | sender: User.user1, 129 | sentAt: 1675408868, 130 | readReceipt: .delivered, 131 | style: .media(.gif(id)) 132 | ) 133 | }() 134 | } 135 | -------------------------------------------------------------------------------- /Sources/ChatUI/Previews/TestModels/User.swift: -------------------------------------------------------------------------------- 1 | // 2 | // User.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/02/08. 6 | // 7 | 8 | import Foundation 9 | 10 | struct User: UserProtocol { 11 | var id: String 12 | var username: String 13 | var imageURL: URL? 14 | } 15 | 16 | extension User { 17 | static let user1 = User( 18 | id: "andrew_parker", 19 | username: "Andrew Parker", 20 | imageURL: URL(string: "https://images.pexels.com/photos/614810/pexels-photo-614810.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1") 21 | ) 22 | 23 | static let user2 = User( 24 | id: "karen.castillo_96", 25 | username: "Karen Castillo", 26 | imageURL: URL(string: "https://images.unsplash.com/photo-1438761681033-6461ffad8d80?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxzZWFyY2h8Mnx8cGVyc29ufGVufDB8fDB8fA%3D%3D&w=1000&q=80") 27 | ) 28 | 29 | static let noImage = User( 30 | id: "lucas.ganimi", 31 | username: "Lucas Ganimi" 32 | ) 33 | 34 | static let starbucks = User( 35 | id: "starbucks", 36 | username: "Starbucks Coffee", 37 | imageURL: URL(string: "https://pbs.twimg.com/profile_images/1268570190855331841/CiNnNX94_400x400.jpg") 38 | ) 39 | 40 | static let bluebottle = User( 41 | id: "bluebottle", 42 | username: "Blue Bottle Coffee", 43 | imageURL: URL(string: "https://pbs.twimg.com/profile_images/1514997622750138368/1mnEPbjo_400x400.jpg") 44 | ) 45 | } 46 | 47 | -------------------------------------------------------------------------------- /Sources/ChatUI/Protocols/ChannelProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChannelProtocol.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/02/08. 6 | // 7 | 8 | import Foundation 9 | 10 | /// A protocol that defines the necessary information for displaying a channel regarding UI. 11 | public protocol ChannelProtocol { 12 | /// The associated type that conforms to ``UserProtocol`` 13 | associatedtype UserType: UserProtocol 14 | /// The associated type that conforms to ``MessageProtocol`` 15 | associatedtype MessageType: MessageProtocol 16 | 17 | var id: String { get } 18 | var name: String { get } 19 | var imageURL: URL? { get } 20 | var createdAt: Double { get } 21 | var members: [UserType] { get } 22 | var lastMessage: MessageType? { get } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/ChatUI/Protocols/KeyboardReadable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeyboardReadable.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/02/08. 6 | // 7 | 8 | import Combine 9 | import SwiftUI 10 | 11 | /// The protoocl that defines the publisher to read the keyboard changes. 12 | protocol KeyboardReadable { 13 | /// The publisher that reads the changes on the keyboard. 14 | var keyboardPublisher: AnyPublisher { get } 15 | } 16 | 17 | extension KeyboardReadable { 18 | var keyboardPublisher: AnyPublisher { 19 | Publishers.Merge( 20 | NotificationCenter.default 21 | .publisher(for: UIResponder.keyboardWillShowNotification) 22 | .map { _ in true }, 23 | 24 | NotificationCenter.default 25 | .publisher(for: UIResponder.keyboardWillHideNotification) 26 | .map { _ in false } 27 | ) 28 | .eraseToAnyPublisher() 29 | } 30 | 31 | /// The publisher that reads the height of the keyboard. 32 | public var keyboardHeight: AnyPublisher { 33 | NotificationCenter.default 34 | .publisher(for: UIResponder.keyboardDidShowNotification) 35 | .map { 36 | if let keyboardFrame: NSValue = $0 37 | .userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue { 38 | let keyboardRectangle = keyboardFrame.cgRectValue 39 | let keyboardHeight = keyboardRectangle.height 40 | return keyboardHeight 41 | } else { 42 | return 0 43 | } 44 | } 45 | .eraseToAnyPublisher() 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Sources/ChatUI/Protocols/MessageProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MessageProtocol.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/02/08. 6 | // 7 | 8 | import Foundation 9 | 10 | /// A protocol that defines the necessary information for displaying a message regarding UI. 11 | public protocol MessageProtocol: Hashable { 12 | /// The associated type that conforms to ``UserProtocol`` 13 | associatedtype UserType: UserProtocol 14 | 15 | var id: String { get } 16 | var sender: UserType { get } 17 | var sentAt: Double { get } 18 | var editedAt: Double? { get } 19 | var readReceipt: ReadReceipt { get } 20 | var style: MessageStyle { get } 21 | } 22 | 23 | /// The protocol to support message reaction features. 24 | /// - IMPORTANT: Currently, it supports single reaction item only that is type of `String`. It's recommeded that uses text emoji such as `"❤️"`, `"🙂"`, `"👍"` and so on. 25 | public protocol MessageReactable: Hashable { 26 | /// The reaction status. 27 | /// - SeeAlso: ``ReactionStatus`` 28 | var reaction: ReactionStatus { get set } 29 | } 30 | 31 | /// The enumeration for message reaction status. 32 | /// - IMPORTANT: Currently, it supports single reaction item only that is type of `String`. It's recommeded that uses text emoji such as `"❤️"`, `"🙂"`, `"👍"` and so on. 33 | public enum ReactionStatus: Equatable, Hashable { 34 | /// There is no reaction item in which the message has. 35 | case none 36 | /// There is a single reaction item on the message. 37 | case reacted(_ item: String) 38 | } 39 | -------------------------------------------------------------------------------- /Sources/ChatUI/Protocols/UserProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserProtocol.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/02/08. 6 | // 7 | 8 | import Foundation 9 | 10 | /// A protocol that defines the necessary information for displaying a user regarding UI. 11 | public protocol UserProtocol: Identifiable, Hashable { 12 | var id: String { get } 13 | var username: String { get } 14 | var imageURL: URL? { get } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/ChatUI/Publishers/CapturedItemPublisher.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CapturedItemPublisher.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/02/12. 6 | // 7 | 8 | import Combine 9 | 10 | /** 11 | The publisher that send events when the camera captured photo or video. The parameter provides a data of the captured item. 12 | */ 13 | public var capturedItemPublisher = PassthroughSubject() 14 | -------------------------------------------------------------------------------- /Sources/ChatUI/Publishers/HighlightMessagePublisher.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HighlightMessagePublisher.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/03/05. 6 | // 7 | 8 | import Combine 9 | 10 | /** 11 | The publisher that send highlight message. 12 | */ 13 | public var highlightMessagePublisher = PassthroughSubject<(any MessageProtocol)?, Never>() 14 | -------------------------------------------------------------------------------- /Sources/ChatUI/Publishers/KeyboardNotificationPublisher.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeyboardNotificationPublisher.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/02/28. 6 | // 7 | 8 | import UIKit 9 | import Combine 10 | 11 | /// The publisher that sends the current visibilty of the keyboard. 12 | /// 13 | /// - When `UIResponder.keyboardWillShowNotification` notification is called, it sends `true`. 14 | /// - When `UIResponder.keyboardWillHideNotification` notification is called, it sends `false`. 15 | /// 16 | /// Example Usage: 17 | /// ```swift 18 | /// .onReceive(keyboardNotificationPublisher) { isShown in 19 | /// isKeyboardShown = isShown 20 | /// } 21 | /// ``` 22 | public var keyboardNotificationPublisher: AnyPublisher { 23 | Publishers 24 | .Merge( 25 | NotificationCenter 26 | .default 27 | .publisher(for: UIResponder.keyboardWillShowNotification) 28 | .map { _ in true }, 29 | NotificationCenter 30 | .default 31 | .publisher(for: UIResponder.keyboardWillHideNotification) 32 | .map { _ in false } 33 | ) 34 | .debounce(for: .seconds(0.1), scheduler: RunLoop.main) 35 | .eraseToAnyPublisher() 36 | } 37 | -------------------------------------------------------------------------------- /Sources/ChatUI/Publishers/KeyboardVisibilityPublisher.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeyboardVisibilityPublisher.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/02/28. 6 | // 7 | 8 | import UIKit 9 | import Combine 10 | 11 | /** 12 | The publisher that publishes event with a boolean value that indicates whether it needs to show/hide keyboard. 13 | */ 14 | public var keyboardVisibilityPublisher = PassthroughSubject() 15 | -------------------------------------------------------------------------------- /Sources/ChatUI/Publishers/LocationSearchResultPublisher.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LocationSearchResultPublisher.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/02/11. 6 | // 7 | 8 | import Combine 9 | import MapKit 10 | 11 | /** 12 | The publisher that send events when the new `MKMapItem`objects are found as the search results. 13 | */ 14 | public var locationSearchResultPublisher = PassthroughSubject<[MKMapItem], Never>() 15 | -------------------------------------------------------------------------------- /Sources/ChatUI/Publishers/MessageReactionPublisher.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MessageReactionPublisher.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/03/05. 6 | // 7 | 8 | import Combine 9 | 10 | /** 11 | The publisher that send reaction item and message ID. 12 | - IMPORTANT: The first parameter is the reaction item. 13 | */ 14 | public var messageReactionPublisher = PassthroughSubject<(String, String), Never>() 15 | 16 | -------------------------------------------------------------------------------- /Sources/ChatUI/Publishers/ScrollDownPublisher.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ScrollDownPublisher.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/02/09. 6 | // 7 | 8 | import Combine 9 | 10 | /** 11 | The publisher that send events when the it needs to scroll to bottom. 12 | 13 | ```swift 14 | // How to publish 15 | let _ = Empty() 16 | .sink( 17 | receiveCompletion: { _ in 18 | scrollDownPublisher.send(()) 19 | }, 20 | receiveValue: { _ in } 21 | ) 22 | ``` 23 | ```swift 24 | // How to subscribe 25 | .onReceive(scrollDownPublisher) { _ in 26 | withAnimation { 27 | scrollView.scrollTo(id, anchor: .bottom) 28 | } 29 | } 30 | ``` 31 | */ 32 | public var scrollDownPublisher = PassthroughSubject() 33 | -------------------------------------------------------------------------------- /Sources/ChatUI/Publishers/ScrolledToEndPublisher.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ScrolledToEndPublisher.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/03/19. 6 | // 7 | 8 | import Combine 9 | 10 | // TODO: Unstable 11 | 12 | /** 13 | The publisher that sends event when the list is scrolled to the end. 14 | 15 | ```swift 16 | // How to publish 17 | scrolledToEndPublisher.send(true) 18 | ``` 19 | ```swift 20 | // How to subscribe 21 | .onReceive(scrolledToEndPublisher) { isEnded in 22 | if isEnded { 23 | loadMoreMessages() 24 | } 25 | } 26 | ``` 27 | 28 | - Important: This publisher is the beta feature. 29 | */ 30 | public var scrolledToEndPublisher = PassthroughSubject() 31 | -------------------------------------------------------------------------------- /Sources/ChatUI/Publishers/SendMessagePublisher.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SendMessagePublisher.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/02/11. 6 | // 7 | 8 | import Combine 9 | 10 | /// The publisher that delivers ``MessageStyle`` 11 | /// ```swift 12 | /// // How to publish 13 | /// let _ = Empty() 14 | /// .sink( 15 | /// receiveCompletion: { _ in 16 | /// // Create `MessageStyle` object 17 | /// let style = MessageStyle.text("{TEXT}") 18 | /// sendMessagePublisher.send(style) 19 | /// }, 20 | /// receiveValue: { _ in } 21 | /// ) 22 | /// ``` 23 | /// ```swift 24 | /// // How to subscribe 25 | /// .onReceive(sendMessagePublisher) { messageStyle in 26 | /// // Handle `messageStyle` here (e.g., sending message with the style) 27 | /// } 28 | /// ``` 29 | public var sendMessagePublisher = PassthroughSubject() 30 | -------------------------------------------------------------------------------- /Sources/ChatUI/ViewModifiers/KeyboardReaderModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeyboardReaderModifier.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/02/08. 6 | // 7 | 8 | import SwiftUI 9 | import Combine 10 | 11 | public class KeyboardReaderModifier { 12 | public struct KeyboardVisibilityReader: ViewModifier { 13 | 14 | var hidesWhenTapped: Bool 15 | 16 | public init(hidesWhenTapped: Bool = true) { 17 | self.hidesWhenTapped = hidesWhenTapped 18 | } 19 | 20 | public func body(content: Content) -> some View { 21 | content 22 | .gesture( 23 | TapGesture().onEnded { _ in 24 | guard hidesWhenTapped else { return } 25 | let _ = Empty() 26 | .sink( 27 | receiveCompletion: { _ in 28 | keyboardVisibilityPublisher.send(false) 29 | }, 30 | receiveValue: { _ in } 31 | ) 32 | } 33 | ) 34 | .onReceive(keyboardVisibilityPublisher) { isVisible in 35 | switch isVisible { 36 | case true: 37 | UIApplication.shared 38 | .sendAction( 39 | #selector(UIResponder.becomeFirstResponder), 40 | to: nil, 41 | from: nil, 42 | for: nil 43 | ) 44 | case false: 45 | UIApplication.shared 46 | .sendAction( 47 | #selector(UIResponder.resignFirstResponder), 48 | to: nil, 49 | from: nil, 50 | for: nil 51 | ) 52 | } 53 | } 54 | } 55 | } 56 | 57 | } 58 | 59 | 60 | 61 | /** 62 | ```swift 63 | @State var isKeyboardShown: Bool = false 64 | 65 | var body: some View { 66 | MessageList() 67 | .keyboardVisibility(isKeyboardShow ? .visible : .hidden) 68 | .onReceive(keyboardPublisher) { value in 69 | isKeyboardShown = value 70 | } 71 | } 72 | ``` 73 | */ 74 | 75 | 76 | extension View { 77 | /** 78 | Update the visibility of the keyboard. 79 | 80 | - sends the `Visibility` value via `keyboardVisibilityPublisher` just one time to change the keyboard visibility state. 81 | - receives `keyboardVisibilityPublisher` events, use `keyboardReader()` modifier. 82 | 83 | - Parameter visibility: `Visibility` value. If it's `.visible`, ``keyboardVisibilityPublisher`` delivers `true`. If it's `.hidden`, ``keyboardVisibilityPublisher`` delivers `false`. If it's `.automatic`, it doesn't send any value via ``keyboardVisibilityPublisher`` 84 | 85 | This examples shows a view that sends the `true` via `keyboardVisibility` publisher to shows keyboard and shows keyboard. 86 | 87 | ```swift 88 | SomeView() 89 | .keyboardVisibility(.visible) // Show up keyboard 90 | ``` 91 | 92 | When you want to receive event only, not send, assign `.automatic` to the parameter(`visibility`). 93 | This examples shows a view that only receives `keyboardVisibilityPublisher` events. 94 | 95 | ```swift 96 | SomeView() 97 | .keyboardVisibility(.automatic) 98 | ``` 99 | 100 | - NOTE: It's' recommended that use `onReceive(_:perform:)` together to receives `keyboardNotificationPublisher` event when use this method. 101 | ```swift 102 | @State var isKeyboardShown: Bool = false 103 | 104 | var body: some View { 105 | SomeView() 106 | .keyboardVisibility(isKeyboardShown ? .visible : .hidden) 107 | .onReceive(keyboardNotificationPublisher) { value in 108 | isKeyboardShown = value 109 | } 110 | } 111 | } 112 | */ 113 | public func keyboard(_ visibility: Visibility) -> some View { 114 | let _ = Empty() 115 | .sink( 116 | receiveCompletion: { _ in 117 | switch visibility { 118 | case .automatic: 119 | return 120 | case .visible: 121 | keyboardVisibilityPublisher.send(true) 122 | case .hidden: 123 | keyboardVisibilityPublisher.send(false) 124 | } 125 | }, 126 | receiveValue: { _ in } 127 | ) 128 | return AnyView( 129 | modifier( 130 | KeyboardReaderModifier.KeyboardVisibilityReader() 131 | ) 132 | 133 | ) 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /Sources/ChatUI/ViewModifiers/MessageListModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MessageListModifier.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/02/08. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public class EffectModifier { 11 | public enum Style { 12 | /// To Flip the view. It rotates the view and scales this view’s rendered output by horizontal amont -1. 13 | case flipped 14 | } 15 | 16 | /// The effect that flips the view. It rotates the view and scales this view’s rendered output by horizontal amont -1. 17 | public struct FlippedEffect: ViewModifier { 18 | public func body(content: Content) -> some View { 19 | content 20 | .rotationEffect(.radians(Double.pi)) 21 | .scaleEffect(x: -1, y: 1, anchor: .center) 22 | } 23 | } 24 | } 25 | 26 | extension View { 27 | /** 28 | Applies the effect such as *flipping* 29 | 30 | ``MessageList`` applies this effect as ``EffectModifier/Style/flipped`` and also the ``EffectModifier/Style/flipped`` is applied to the `rowContent` of the ``MessageList`` 31 | 32 | **Example usage:** 33 | 34 | ```swift 35 | MessageRow(message: message) 36 | .effect(.flipped) 37 | ``` 38 | */ 39 | public func effect(_ style: EffectModifier.Style) -> some View { 40 | switch style { 41 | case .flipped: 42 | return AnyView(modifier(EffectModifier.FlippedEffect())) 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/ChatUI/ViewModifiers/MessageModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MessageModifier.swift 3 | // 4 | // 5 | // Created by Jaesung Lee on 2023/02/08. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public class MessageModifier { 11 | public enum Style { 12 | case remoteBody(_ lineLimit: Int?) 13 | case localBody(_ lineLimit: Int?) 14 | case date 15 | case receipt 16 | case senderName 17 | case senderProfile 18 | } 19 | 20 | public static func remoteBodyStyle(_ lineLimit: Int?) -> RemoteBody { 21 | RemoteBody(lineLimit: lineLimit) 22 | } 23 | 24 | public static func localBodyStyle(_ lineLimit: Int?) -> LocalBody { 25 | LocalBody(lineLimit: lineLimit) 26 | } 27 | 28 | public static var dateStyle = Date() 29 | 30 | public static var receiptStyle = Receipt() 31 | 32 | public static var senderName = SenderName() 33 | 34 | public static var senderProfile = SenderProfile() 35 | 36 | public struct RemoteBody: ViewModifier { 37 | @Environment(\.appearance) var appearance 38 | 39 | let lineLimit: Int? 40 | 41 | public func body(content: Content) -> some View { 42 | content 43 | .lineLimit(lineLimit) 44 | .font(appearance.body) 45 | .frame(minWidth: 18) /// To make the bubble to be a circle shape, when the text is too short 46 | .padding(12) 47 | .foregroundColor(appearance.primary) 48 | .background(appearance.remoteMessageBackground) 49 | .clipShape(RoundedRectangle(cornerRadius: 21)) 50 | } 51 | } 52 | 53 | public struct LocalBody: ViewModifier { 54 | @Environment(\.appearance) var appearance 55 | 56 | let lineLimit: Int? 57 | 58 | public func body(content: Content) -> some View { 59 | content 60 | .lineLimit(lineLimit) 61 | .font(appearance.body) 62 | .frame(minWidth: 18) /// To make the bubble to be a circle shape, when the text is too short 63 | .padding(12) 64 | .foregroundColor(appearance.background) 65 | .background(appearance.localMessageBackground) 66 | .clipShape(RoundedRectangle(cornerRadius: 21)) 67 | } 68 | } 69 | 70 | public struct Date: ViewModifier { 71 | @Environment(\.appearance) var appearance 72 | 73 | public func body(content: Content) -> some View { 74 | content 75 | .font(appearance.caption) 76 | .foregroundColor(appearance.secondary) 77 | } 78 | } 79 | 80 | public struct Receipt: ViewModifier { 81 | 82 | public func body(content: Content) -> some View { 83 | content 84 | .font(.largeTitle) 85 | } 86 | } 87 | 88 | public struct SenderName: ViewModifier { 89 | @Environment(\.appearance) var appearance 90 | 91 | public func body(content: Content) -> some View { 92 | content 93 | .font(appearance.footnote) 94 | .foregroundColor(appearance.secondary) 95 | } 96 | } 97 | 98 | public struct SenderProfile: ViewModifier { 99 | @Environment(\.appearance) var appearance 100 | 101 | public func body(content: Content) -> some View { 102 | content 103 | .frame(width: 24, height: 24) 104 | .aspectRatio(contentMode: .fill) 105 | .clipShape(Circle()) 106 | .padding(1) 107 | .background { 108 | appearance.imagePlaceholder 109 | .clipShape(Circle()) 110 | } 111 | } 112 | } 113 | } 114 | 115 | extension View { 116 | public func messageStyle(_ style: MessageModifier.Style) -> some View { 117 | switch style { 118 | case .remoteBody(let lineLimit): 119 | return AnyView(modifier(MessageModifier.remoteBodyStyle(lineLimit))) 120 | case .localBody(let lineLimit): 121 | return AnyView(modifier(MessageModifier.localBodyStyle(lineLimit))) 122 | case .date: 123 | return AnyView(modifier(MessageModifier.dateStyle)) 124 | case .receipt: 125 | return AnyView(modifier(MessageModifier.receiptStyle)) 126 | case .senderName: 127 | return AnyView(modifier(MessageModifier.senderName)) 128 | case .senderProfile: 129 | return AnyView(modifier(MessageModifier.senderProfile)) 130 | } 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /Tests/ChatUITests/ChatUITests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import ChatUI 3 | 4 | final class ChatUITests: XCTestCase { 5 | func testExample() throws { 6 | // This is an example of a functional test case. 7 | // Use XCTAssert and related functions to verify your tests produce the correct 8 | // results. 9 | XCTAssertEqual(ChatUI().text, "Hello, World!") 10 | } 11 | } 12 | --------------------------------------------------------------------------------