Render location events in the timeline (#1136)

* Start location integration in the timeline

* Add warning

* Add LocationRoomTimelineView

* Add GeoURI

* Refine LocationRoomTimelineView

* Refine LocationRoomTimelineView

* Add LocationRoomTimelineView previews

* Cleanup code

* Mock reply

* Add FF for location events

* Update project file

* Fix MapLibreStaticMapView placeholder

* Add MapTilerAttribution

* Add LocationPinView

* Fix layout

* Start reply rendering

* Refactor ReplyView.Icon

* Add localisations

* Fix reactions

* Amend GeoURI regex

* Add log

* Fix tests

* Attribution placement refactor

* Cleanup

* Replace NSRegularExpression with a regex

* Cleanup

* Update unit tests

* isMediaType -> shouldFillBubble
This commit is contained in:
Alfonso Grillo
2023-06-23 12:49:35 +02:00
committed by GitHub
parent 40fab35dd7
commit 07a6235fac
27 changed files with 886 additions and 65 deletions
@@ -34,7 +34,7 @@ struct TimelineReplyView: View {
ReplyView(sender: sender,
plainBody: content.body,
formattedBody: nil,
icon: .init(systemIconName: "waveform", cornerRadii: iconCornerRadii))
icon: .init(kind: .systemIcon("waveform"), cornerRadii: iconCornerRadii))
case .emote(let content):
ReplyView(sender: sender,
plainBody: content.body,
@@ -43,12 +43,12 @@ struct TimelineReplyView: View {
ReplyView(sender: sender,
plainBody: content.body,
formattedBody: nil,
icon: .init(systemIconName: "doc.text.fill", cornerRadii: iconCornerRadii))
icon: .init(kind: .systemIcon("waveform"), cornerRadii: iconCornerRadii))
case .image(let content):
ReplyView(sender: sender,
plainBody: content.body,
formattedBody: nil,
icon: .init(mediaSource: content.thumbnailSource ?? content.source, cornerRadii: iconCornerRadii))
icon: .init(kind: .mediaSource(content.thumbnailSource ?? content.source), cornerRadii: iconCornerRadii))
case .notice(let content):
ReplyView(sender: sender,
plainBody: content.body,
@@ -61,7 +61,12 @@ struct TimelineReplyView: View {
ReplyView(sender: sender,
plainBody: content.body,
formattedBody: nil,
icon: .init(mediaSource: content.thumbnailSource, cornerRadii: iconCornerRadii))
icon: content.thumbnailSource.map { .init(kind: .mediaSource($0), cornerRadii: iconCornerRadii) })
case .location:
ReplyView(sender: sender,
plainBody: L10n.commonSharedLocation,
formattedBody: nil,
icon: .init(kind: .icon(Asset.Images.locationPin.name), cornerRadii: iconCornerRadii))
}
default:
LoadingReplyView()
@@ -87,8 +92,13 @@ struct TimelineReplyView: View {
private struct ReplyView: View {
struct Icon {
var mediaSource: MediaSourceProxy?
var systemIconName: String?
enum Kind {
case mediaSource(MediaSourceProxy)
case systemIcon(String)
case icon(String)
}
let kind: Kind
let cornerRadii: Double
}
@@ -102,7 +112,7 @@ struct TimelineReplyView: View {
var icon: Icon?
var isTextOnly: Bool {
icon?.mediaSource == nil && icon?.systemIconName == nil
icon == nil
}
/// The string shown as the message preview.
@@ -141,22 +151,28 @@ struct TimelineReplyView: View {
@ViewBuilder
private var iconView: some View {
if let mediaSource = icon?.mediaSource {
LoadableImage(mediaSource: mediaSource,
size: .init(width: imageContainerSize,
height: imageContainerSize),
imageProvider: context.imageProvider) {
Image(systemName: "photo")
.padding(4.0)
if let icon {
switch icon.kind {
case .mediaSource(let mediaSource):
LoadableImage(mediaSource: mediaSource,
size: .init(width: imageContainerSize,
height: imageContainerSize),
imageProvider: context.imageProvider) {
Image(systemName: "photo")
.padding(4.0)
}
.aspectRatio(contentMode: .fill)
case .systemIcon(let systemIconName):
Image(systemName: systemIconName)
.resizable()
.aspectRatio(contentMode: .fit)
.padding(8.0)
case .icon(let iconName):
Image(iconName)
.resizable()
.aspectRatio(contentMode: .fit)
.padding(8.0)
}
.aspectRatio(contentMode: .fill)
}
if let systemIconName = icon?.systemIconName {
Image(systemName: systemIconName)
.resizable()
.aspectRatio(contentMode: .fit)
.padding(8.0)
}
}
}
@@ -211,6 +227,9 @@ struct TimelineReplyView_Previews: PreviewProvider {
duration: 0,
source: nil,
thumbnailSource: imageSource))))
TimelineReplyView(placement: .timeline,
timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
contentType: .location(.init(body: "", geoURI: nil))))
}
.environmentObject(viewModel.context)
}
@@ -122,7 +122,7 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
@ViewBuilder
var styledContent: some View {
if isMediaType {
if shouldFillBubble {
contentWithTimestamp
.bubbleStyle(inset: false,
cornerRadius: cornerRadius,
@@ -138,7 +138,7 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
@ViewBuilder
var contentWithTimestamp: some View {
if isTextItem || isMediaType {
if isTextItem || shouldFillBubble {
ZStack(alignment: .bottomTrailing) {
contentWithReply
interactiveLocalizedSendInfo
@@ -165,7 +165,7 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
@ViewBuilder
var backgroundedLocalizedSendInfo: some View {
if isMediaType {
if shouldFillBubble {
localizedSendInfo
.padding(.horizontal, 4)
.padding(.vertical, 2)
@@ -194,7 +194,7 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
}
.font(.compound.bodyXS)
.foregroundColor(timelineItem.properties.deliveryStatus == .sendingFailed ? .compound.textCriticalPrimary : .compound.textSecondary)
.padding(.bottom, isMediaType ? 0 : -4)
.padding(.bottom, shouldFillBubble ? 0 : -4)
}
@ViewBuilder
@@ -230,8 +230,17 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
return timelineGroupStyle == .single || timelineGroupStyle == .first ? 8 : 0
}
private var isMediaType: Bool {
timelineItem is ImageRoomTimelineItem || timelineItem is VideoRoomTimelineItem || timelineItem is StickerRoomTimelineItem
private var shouldFillBubble: Bool {
switch timelineItem {
case is ImageRoomTimelineItem,
is VideoRoomTimelineItem,
is StickerRoomTimelineItem:
return true
case let locationTimelineItem as LocationRoomTimelineItem:
return locationTimelineItem.content.geoURI != nil
default:
return false
}
}
private var alignment: HorizontalAlignment {
@@ -0,0 +1,78 @@
//
// Copyright 2023 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
struct LocationRoomTimelineView: View {
let timelineItem: LocationRoomTimelineItem
@Environment(\.timelineStyle) var timelineStyle
var body: some View {
TimelineStyler(timelineItem: timelineItem) {
if let geoURI = timelineItem.content.geoURI {
let mapSize: CGSize = .init(width: 292, height: 188)
MapLibreStaticMapView(geoURI: geoURI, size: mapSize) {
LocationPinView()
}
.frame(width: mapSize.width, height: mapSize.height)
} else {
FormattedBodyText(text: timelineItem.body)
}
}
}
}
private extension MapLibreStaticMapView {
init(geoURI: GeoURI, size: CGSize, @ViewBuilder pinAnnotationView: () -> PinAnnotation) {
self.init(coordinates: .init(latitude: geoURI.latitude, longitude: geoURI.longitude),
zoomLevel: 15,
imageSize: size,
attributionPlacement: .bottomLeft,
mapTilerStatic: MapTilerStaticMap(key: ServiceLocator.shared.settings.mapTilerApiKey,
lightURL: ServiceLocator.shared.settings.lightTileMapStyleURL,
darkURL: ServiceLocator.shared.settings.darkTileMapStyleURL),
pinAnnotationView: pinAnnotationView)
}
}
struct LocationRoomTimelineView_Previews: PreviewProvider {
static let viewModel = RoomScreenViewModel.mock
static var previews: some View {
body
.environmentObject(viewModel.context)
body
.environment(\.timelineStyle, .plain)
.environmentObject(viewModel.context)
}
@ViewBuilder
static var body: some View {
LocationRoomTimelineView(timelineItem: .init(id: UUID().uuidString,
timestamp: "Now",
isOutgoing: false,
isEditable: false,
sender: .init(id: "Bob"),
content: .init(body: "Fallback geo uri description", geoURI: nil)))
LocationRoomTimelineView(timelineItem: .init(id: UUID().uuidString,
timestamp: "Now",
isOutgoing: false,
isEditable: false,
sender: .init(id: "Bob"),
content: .init(body: "Fallback geo uri description", geoURI: .init(latitude: 41.902782, longitude: 12.496366))))
}
}
@@ -29,6 +29,7 @@ struct DeveloperOptionsScreenViewStateBindings {
var userSuggestionsEnabled: Bool
var readReceiptsEnabled: Bool
var isEncryptionSyncEnabled: Bool
var locationEventsEnabled: Bool
}
enum DeveloperOptionsScreenViewAction {
@@ -36,5 +37,6 @@ enum DeveloperOptionsScreenViewAction {
case changedUserSuggestionsEnabled
case changedReadReceiptsEnabled
case changedIsEncryptionSyncEnabled
case changedLocationEventsEnabled
case clearCache
}
@@ -28,7 +28,8 @@ class DeveloperOptionsScreenViewModel: DeveloperOptionsScreenViewModelType, Deve
let bindings = DeveloperOptionsScreenViewStateBindings(shouldCollapseRoomStateEvents: appSettings.shouldCollapseRoomStateEvents,
userSuggestionsEnabled: appSettings.userSuggestionsEnabled,
readReceiptsEnabled: appSettings.readReceiptsEnabled,
isEncryptionSyncEnabled: appSettings.isEncryptionSyncEnabled)
isEncryptionSyncEnabled: appSettings.isEncryptionSyncEnabled,
locationEventsEnabled: appSettings.locationEventsEnabled)
let state = DeveloperOptionsScreenViewState(bindings: bindings)
super.init(initialViewState: state)
@@ -48,6 +49,8 @@ class DeveloperOptionsScreenViewModel: DeveloperOptionsScreenViewModelType, Deve
appSettings.readReceiptsEnabled = state.bindings.readReceiptsEnabled
case .changedIsEncryptionSyncEnabled:
appSettings.isEncryptionSyncEnabled = state.bindings.isEncryptionSyncEnabled
case .changedLocationEventsEnabled:
appSettings.locationEventsEnabled = state.bindings.locationEventsEnabled
case .clearCache:
callback?(.clearCache)
}
@@ -52,6 +52,13 @@ struct DeveloperOptionsScreen: View {
.onChange(of: context.isEncryptionSyncEnabled) { _ in
context.send(viewAction: .changedIsEncryptionSyncEnabled)
}
Toggle(isOn: $context.locationEventsEnabled) {
Text("Location events in timeline")
}
.onChange(of: context.locationEventsEnabled) { _ in
context.send(viewAction: .changedLocationEventsEnabled)
}
}
Section {