mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 10:33:45 +00:00
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:
@@ -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))))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user