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
@@ -0,0 +1,22 @@
{
"images" : [
{
"filename" : "location-pin.pdf",
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "location-pin-dark.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,170 @@
%PDF-1.7
1 0 obj
<< /Type /XObject
/Length 2 0 R
/Group << /Type /Group
/S /Transparency
>>
/Subtype /Form
/Resources << >>
/BBox [ 0.000000 0.000000 50.000000 54.000000 ]
>>
stream
/DeviceRGB CS
/DeviceRGB cs
q
1.000000 0.000000 -0.000000 1.000000 16.600098 16.852539 cm
0.062745 0.074510 0.090196 scn
8.400001 24.147461 m
3.756000 24.147461 0.000000 20.289217 0.000000 15.518802 c
0.000000 10.378586 5.304000 3.290758 7.488000 0.591221 c
7.968000 -0.000460 8.844001 -0.000460 9.324001 0.591221 c
11.496001 3.290758 16.800001 10.378586 16.800001 15.518802 c
16.800001 20.289217 13.044001 24.147461 8.400001 24.147461 c
h
8.400001 12.437138 m
6.744000 12.437138 5.400000 13.817722 5.400000 15.518802 c
5.400000 17.219879 6.744000 18.600466 8.400001 18.600466 c
10.056001 18.600466 11.400001 17.219879 11.400001 15.518802 c
11.400001 13.817722 10.056001 12.437138 8.400001 12.437138 c
h
f
n
Q
endstream
endobj
2 0 obj
710
endobj
3 0 obj
<< /Type /XObject
/Length 4 0 R
/Group << /Type /Group
/S /Transparency
>>
/Subtype /Form
/Resources << >>
/BBox [ 0.000000 0.000000 50.000000 54.000000 ]
>>
stream
/DeviceRGB CS
/DeviceRGB cs
q
1.000000 0.000000 -0.000000 1.000000 13.000000 17.000000 cm
0.000000 0.000000 0.000000 scn
0.000000 24.000000 m
24.000000 24.000000 l
24.000000 0.000000 l
0.000000 0.000000 l
0.000000 24.000000 l
h
f
n
Q
endstream
endobj
4 0 obj
234
endobj
5 0 obj
<< /XObject << /X1 1 0 R >>
/ExtGState << /E1 << /SMask << /Type /Mask
/G 3 0 R
/S /Alpha
>>
/Type /ExtGState
>> >>
>>
endobj
6 0 obj
<< /Length 7 0 R >>
stream
/DeviceRGB CS
/DeviceRGB cs
q
-1.000000 0.000000 -0.000000 -1.000000 32.000000 6.000000 cm
0.921569 0.933333 0.949020 scn
7.000000 6.000000 m
13.062179 0.000001 l
0.937822 0.000001 l
7.000000 6.000000 l
h
f
n
Q
q
1.000000 0.000000 -0.000000 1.000000 0.000000 4.000000 cm
0.921569 0.933333 0.949020 scn
50.000000 25.000000 m
50.000000 11.192883 38.807117 0.000000 25.000000 0.000000 c
11.192882 0.000000 0.000000 11.192883 0.000000 25.000000 c
0.000000 38.807117 11.192882 50.000000 25.000000 50.000000 c
38.807117 50.000000 50.000000 38.807117 50.000000 25.000000 c
h
f
n
Q
q
/E1 gs
/X1 Do
Q
endstream
endobj
7 0 obj
592
endobj
8 0 obj
<< /Annots []
/Type /Page
/MediaBox [ 0.000000 0.000000 50.000000 54.000000 ]
/Resources 5 0 R
/Contents 6 0 R
/Parent 9 0 R
>>
endobj
9 0 obj
<< /Kids [ 8 0 R ]
/Count 1
/Type /Pages
>>
endobj
10 0 obj
<< /Pages 9 0 R
/Type /Catalog
>>
endobj
xref
0 11
0000000000 65535 f
0000000010 00000 n
0000000968 00000 n
0000000990 00000 n
0000001472 00000 n
0000001494 00000 n
0000001792 00000 n
0000002440 00000 n
0000002462 00000 n
0000002635 00000 n
0000002709 00000 n
trailer
<< /ID [ (some) (id) ]
/Root 10 0 R
/Size 11
>>
startxref
2769
%%EOF
@@ -0,0 +1,170 @@
%PDF-1.7
1 0 obj
<< /Type /XObject
/Length 2 0 R
/Group << /Type /Group
/S /Transparency
>>
/Subtype /Form
/Resources << >>
/BBox [ 0.000000 0.000000 50.000000 54.000000 ]
>>
stream
/DeviceRGB CS
/DeviceRGB cs
q
1.000000 0.000000 -0.000000 1.000000 16.600098 16.852539 cm
1.000000 1.000000 1.000000 scn
8.400001 24.147461 m
3.756000 24.147461 0.000000 20.289217 0.000000 15.518802 c
0.000000 10.378586 5.304000 3.290758 7.488000 0.591221 c
7.968000 -0.000460 8.844001 -0.000460 9.324001 0.591221 c
11.496001 3.290758 16.800001 10.378586 16.800001 15.518802 c
16.800001 20.289217 13.044001 24.147461 8.400001 24.147461 c
h
8.400001 12.437138 m
6.744000 12.437138 5.400000 13.817722 5.400000 15.518802 c
5.400000 17.219879 6.744000 18.600466 8.400001 18.600466 c
10.056001 18.600466 11.400001 17.219879 11.400001 15.518802 c
11.400001 13.817722 10.056001 12.437138 8.400001 12.437138 c
h
f
n
Q
endstream
endobj
2 0 obj
710
endobj
3 0 obj
<< /Type /XObject
/Length 4 0 R
/Group << /Type /Group
/S /Transparency
>>
/Subtype /Form
/Resources << >>
/BBox [ 0.000000 0.000000 50.000000 54.000000 ]
>>
stream
/DeviceRGB CS
/DeviceRGB cs
q
1.000000 0.000000 -0.000000 1.000000 13.000000 17.000000 cm
0.000000 0.000000 0.000000 scn
0.000000 24.000000 m
24.000000 24.000000 l
24.000000 0.000000 l
0.000000 0.000000 l
0.000000 24.000000 l
h
f
n
Q
endstream
endobj
4 0 obj
234
endobj
5 0 obj
<< /XObject << /X1 1 0 R >>
/ExtGState << /E1 << /SMask << /Type /Mask
/G 3 0 R
/S /Alpha
>>
/Type /ExtGState
>> >>
>>
endobj
6 0 obj
<< /Length 7 0 R >>
stream
/DeviceRGB CS
/DeviceRGB cs
q
-1.000000 0.000000 -0.000000 -1.000000 32.000000 6.000000 cm
0.105882 0.113725 0.133333 scn
7.000000 6.000000 m
13.062179 0.000001 l
0.937822 0.000001 l
7.000000 6.000000 l
h
f
n
Q
q
1.000000 0.000000 -0.000000 1.000000 0.000000 4.000000 cm
0.105882 0.113725 0.133333 scn
50.000000 25.000000 m
50.000000 11.192883 38.807117 0.000000 25.000000 0.000000 c
11.192882 0.000000 0.000000 11.192883 0.000000 25.000000 c
0.000000 38.807117 11.192882 50.000000 25.000000 50.000000 c
38.807117 50.000000 50.000000 38.807117 50.000000 25.000000 c
h
f
n
Q
q
/E1 gs
/X1 Do
Q
endstream
endobj
7 0 obj
592
endobj
8 0 obj
<< /Annots []
/Type /Page
/MediaBox [ 0.000000 0.000000 50.000000 54.000000 ]
/Resources 5 0 R
/Contents 6 0 R
/Parent 9 0 R
>>
endobj
9 0 obj
<< /Kids [ 8 0 R ]
/Count 1
/Type /Pages
>>
endobj
10 0 obj
<< /Pages 9 0 R
/Type /Catalog
>>
endobj
xref
0 11
0000000000 65535 f
0000000010 00000 n
0000000968 00000 n
0000000990 00000 n
0000001472 00000 n
0000001494 00000 n
0000001792 00000 n
0000002440 00000 n
0000002462 00000 n
0000002635 00000 n
0000002709 00000 n
trailer
<< /ID [ (some) (id) ]
/Root 10 0 R
/Size 11
>>
startxref
2769
%%EOF
@@ -107,6 +107,7 @@
"common_server_not_supported" = "Server not supported";
"common_server_url" = "Server URL";
"common_settings" = "Settings";
"common_shared_location" = "Shared location";
"common_starting_chat" = "Starting chat…";
"common_sticker" = "Sticker";
"common_success" = "Success";
@@ -29,6 +29,7 @@ final class AppSettings {
case shouldCollapseRoomStateEvents
case userSuggestionsEnabled
case readReceiptsEnabled
case locationEventsEnabled
}
private static var suiteName: String = InfoPlistReader.main.appGroupIdentifier
@@ -185,13 +186,12 @@ final class AppSettings {
// MARK: - Feature Flags
// MARK: Start Chat
@UserPreference(key: UserDefaultsKeys.userSuggestionsEnabled, defaultValue: false, storageType: .volatile)
var userSuggestionsEnabled
// MARK: Receipts
@UserPreference(key: UserDefaultsKeys.readReceiptsEnabled, defaultValue: false, storageType: .userDefaults(store))
var readReceiptsEnabled
@UserPreference(key: UserDefaultsKeys.locationEventsEnabled, defaultValue: false, storageType: .userDefaults(store))
var locationEventsEnabled
}
+1
View File
@@ -38,6 +38,7 @@ internal enum Asset {
internal static let launchLogo = ImageAsset(name: "Images/LaunchLogo")
internal static let appLogo = ImageAsset(name: "Images/app-logo")
internal static let closeCircle = ImageAsset(name: "Images/close_circle")
internal static let locationPin = ImageAsset(name: "Images/location-pin")
internal static let timelineComposerSendMessage = ImageAsset(name: "Images/timelineComposerSendMessage")
}
}
+2
View File
@@ -238,6 +238,8 @@ public enum L10n {
public static var commonServerUrl: String { return L10n.tr("Localizable", "common_server_url") }
/// Settings
public static var commonSettings: String { return L10n.tr("Localizable", "common_settings") }
/// Shared location
public static var commonSharedLocation: String { return L10n.tr("Localizable", "common_shared_location") }
/// Starting chat…
public static var commonStartingChat: String { return L10n.tr("Localizable", "common_starting_chat") }
/// Sticker
@@ -38,3 +38,11 @@ enum MapLibreError: Error {
case failedLocatingUser
case invalidLocationAuthorization
}
enum MapTilerAttributionPlacement: String {
case bottomRight = "bottomright"
case bottomLeft = "bottomleft"
case topLeft = "topleft"
case topRight = "topright"
case hidden = "false"
}
@@ -21,27 +21,34 @@ struct MapLibreStaticMapView<PinAnnotation: View>: View {
private let coordinates: CLLocationCoordinate2D
private let zoomLevel: Double
private let mapTilerStatic: MapTilerStaticMapProtocol
private let mapTilerAttributionPlacement: MapTilerAttributionPlacement
private let pinAnnotationView: PinAnnotation
@Environment(\.colorScheme) private var colorScheme
@ScaledMetric private var height: CGFloat
@ScaledMetric private var width: CGFloat
@State private var attempt = 0
private let imageSize: CGSize
@State private var fetchAttempt = 0
init(coordinates: CLLocationCoordinate2D, zoomLevel: Double, mapTilerStatic: MapTilerStaticMapProtocol, height: CGFloat, width: CGFloat, @ViewBuilder pinAnnotationView: () -> PinAnnotation) {
init(coordinates: CLLocationCoordinate2D,
zoomLevel: Double,
imageSize: CGSize,
attributionPlacement: MapTilerAttributionPlacement,
mapTilerStatic: MapTilerStaticMapProtocol,
@ViewBuilder pinAnnotationView: () -> PinAnnotation) {
self.coordinates = coordinates
self.zoomLevel = zoomLevel
self.mapTilerStatic = mapTilerStatic
_height = .init(wrappedValue: height)
_width = .init(wrappedValue: width)
self.imageSize = imageSize
mapTilerAttributionPlacement = attributionPlacement
self.pinAnnotationView = pinAnnotationView()
}
var body: some View {
if let url = mapTilerStatic.staticMapURL(for: colorScheme.mapStyle, coordinates: coordinates, zoomLevel: zoomLevel, size: .init(width: width, height: height)) {
if let url = mapTilerStatic.staticMapURL(for: colorScheme.mapStyle, coordinates: coordinates, zoomLevel: zoomLevel, size: imageSize, attribution: mapTilerAttributionPlacement) {
AsyncImage(url: url) { phase in
switch phase {
case .empty:
Image("mapBlurred")
.resizable()
.aspectRatio(contentMode: .fill)
case .success(let image):
ZStack {
image
@@ -55,17 +62,16 @@ struct MapLibreStaticMapView<PinAnnotation: View>: View {
EmptyView()
}
}
.id(attempt)
.frame(width: width, height: height)
.id(fetchAttempt)
.clipped()
} else {
Image("mapBlurred")
}
}
var errorView: some View {
private var errorView: some View {
Button {
attempt += 1
fetchAttempt += 1
} label: {
ZStack {
Image("mapBlurred")
@@ -95,8 +101,9 @@ struct MapLibreStaticMapView_Previews: PreviewProvider {
static var previews: some View {
MapLibreStaticMapView(coordinates: CLLocationCoordinate2D(),
zoomLevel: 15,
mapTilerStatic: MapTilerStaticMapMock(),
height: 150, width: 300) {
imageSize: .init(width: 300, height: 200),
attributionPlacement: .bottomLeft,
mapTilerStatic: MapTilerStaticMapMock()) {
Image(systemName: "mappin.circle.fill")
.padding(.bottom, 35)
}
@@ -104,7 +111,7 @@ struct MapLibreStaticMapView_Previews: PreviewProvider {
}
private struct MapTilerStaticMapMock: MapTilerStaticMapProtocol {
func staticMapURL(for style: MapTilerStyle, coordinates: CLLocationCoordinate2D, zoomLevel: Double, size: CGSize) -> URL? {
func staticMapURL(for style: MapTilerStyle, coordinates: CLLocationCoordinate2D, zoomLevel: Double, size: CGSize, attribution: MapTilerAttributionPlacement) -> URL? {
switch style {
case .light:
return URL(string: "https://www.maptiler.com/img/cloud/home/map5.webp")
@@ -27,7 +27,7 @@ struct MapTilerStaticMap: MapTilerStaticMapProtocol {
self.key = key
}
func staticMapURL(for style: MapTilerStyle, coordinates: CLLocationCoordinate2D, zoomLevel: Double, size: CGSize) -> URL? {
func staticMapURL(for style: MapTilerStyle, coordinates: CLLocationCoordinate2D, zoomLevel: Double, size: CGSize, attribution: MapTilerAttributionPlacement) -> URL? {
var path: String
switch style {
case .light:
@@ -38,7 +38,8 @@ struct MapTilerStaticMap: MapTilerStaticMapProtocol {
path.append(String(format: "/static/%f,%f,%f/%dx%d@2x.png", coordinates.longitude, coordinates.latitude, zoomLevel, Int(size.width), Int(size.height)))
guard let url = URL(string: path) else { return nil }
guard var url = URL(string: path) else { return nil }
url.append(queryItems: [.init(name: "attribution", value: attribution.rawValue)])
let authorization = MapTilerAuthorization(key: key)
return authorization.authorizeURL(url)
}
@@ -17,5 +17,9 @@
import CoreLocation
protocol MapTilerStaticMapProtocol {
func staticMapURL(for style: MapTilerStyle, coordinates: CLLocationCoordinate2D, zoomLevel: Double, size: CGSize) -> URL?
func staticMapURL(for style: MapTilerStyle,
coordinates: CLLocationCoordinate2D,
zoomLevel: Double,
size: CGSize,
attribution: MapTilerAttributionPlacement) -> URL?
}
@@ -0,0 +1,38 @@
//
// 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 LocationPinView: View {
var body: some View {
Image(Asset.Images.locationPin.name)
.alignmentGuide(VerticalAlignment.center) { dimensions in
dimensions[.bottom]
}
.shadow(color: .black.opacity(0.2), radius: 8, x: 0, y: 5)
}
}
struct LocationPinView_Previews: PreviewProvider {
static var previews: some View {
VStack(spacing: 30) {
LocationPinView()
LocationPinView()
.colorScheme(.dark)
}
}
}
@@ -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 {
@@ -0,0 +1,73 @@
//
// 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 Foundation
/// A structure that parses a geo URI (i.e. geo:53.99803101552848,-8.25347900390625;u=10) and constructs their constituent parts.
struct GeoURI: Hashable {
// MARK: - Properties
let latitude: Double
let longitude: Double
let uncertainty: Double?
// MARK: - Setup
init?(string: String) {
guard let parsedURI = Self.parseGeoURI(from: string) else {
MXLog.warning("\(Self.self) failed to parse the string: \(string)")
return nil
}
self = parsedURI
}
init(latitude: Double, longitude: Double, uncertainty: Double? = nil) {
self.latitude = latitude
self.longitude = longitude
self.uncertainty = uncertainty
}
var string: String {
if let uncertainty {
return "geo:\(latitude),\(longitude);u=\(uncertainty)"
} else {
return "geo:\(latitude),\(longitude)"
}
}
// MARK: - Private
// Parse a geo URI string like "geo:53.99803101552848,-8.25347900390625;u=10"
private static func parseGeoURI(from string: String) -> GeoURI? {
guard
let matchOutput = try? RegexGeoURI.standard.wholeMatch(in: string)?.output,
let latitude = Double(matchOutput.latitude),
let longitude = Double(matchOutput.longitude)
else {
return nil
}
let uncertainty = matchOutput.uncertainty.flatMap(Double.init)
return .init(latitude: latitude, longitude: longitude, uncertainty: uncertainty)
}
}
// swiftlint:disable:next large_tuple
private typealias RegexGeoURI = Regex<(Substring, latitude: Substring, longitude: Substring, uncertainty: Substring?)>
private extension RegexGeoURI {
static let standard: Self = /geo:(?<latitude>-?\d+(?:\.\d+)?),(?<longitude>-?\d+(?:\.\d+)?)(?:;u=(?<uncertainty>\d+(?:\.\d+)?))?/
}
@@ -24,6 +24,7 @@ enum EventBasedMessageTimelineItemContentType: Hashable {
case notice(NoticeRoomTimelineItemContent)
case text(TextRoomTimelineItemContent)
case video(VideoRoomTimelineItemContent)
case location(LocationRoomTimelineItemContent)
}
protocol EventBasedMessageTimelineItemProtocol: EventBasedTimelineItemProtocol {
@@ -0,0 +1,39 @@
//
// 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.
//
struct LocationRoomTimelineItem: EventBasedMessageTimelineItemProtocol, Hashable {
let id: String
let timestamp: String
let isOutgoing: Bool
let isEditable: Bool
let sender: TimelineItemSender
let content: LocationRoomTimelineItemContent
var body: String {
content.body
}
var replyDetails: TimelineItemReplyDetails?
var properties = RoomTimelineItemProperties()
var contentType: EventBasedMessageTimelineItemContentType {
.location(content)
}
}
@@ -0,0 +1,20 @@
//
// 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.
//
struct LocationRoomTimelineItemContent: Hashable {
let body: String
let geoURI: GeoURI?
}
@@ -36,7 +36,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
self.stateEventStringBuilder = stateEventStringBuilder
}
// swiftlint:disable:next cyclomatic_complexity
// swiftlint:disable:next cyclomatic_complexity function_body_length
func buildTimelineItem(for eventItemProxy: EventTimelineItemProxy) -> RoomTimelineItemProtocol? {
let isOutgoing = eventItemProxy.isOwn
@@ -74,8 +74,11 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
return buildEmoteTimelineItem(for: eventItemProxy, messageTimelineItem, content, isOutgoing)
case .audio(let content):
return buildAudioTimelineItem(for: eventItemProxy, messageTimelineItem, content, isOutgoing)
case .location:
return nil
case .location(let content):
guard ServiceLocator.shared.settings.locationEventsEnabled else {
return nil
}
return buildLocationTimelineItem(for: eventItemProxy, messageTimelineItem, content, isOutgoing)
case .none:
return nil
}
@@ -298,6 +301,24 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
orderedReadReceipts: orderReadReceipts(eventItemProxy.readReceipts),
transactionID: eventItemProxy.transactionID))
}
private func buildLocationTimelineItem(for eventItemProxy: EventTimelineItemProxy,
_ messageTimelineItem: Message,
_ messageContent: LocationContent,
_ isOutgoing: Bool) -> RoomTimelineItemProtocol {
LocationRoomTimelineItem(id: eventItemProxy.id,
timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened),
isOutgoing: isOutgoing,
isEditable: eventItemProxy.isEditable,
sender: eventItemProxy.sender,
content: buildLocationTimelineItemContent(messageContent),
replyDetails: buildReplyToDetailsFrom(details: messageTimelineItem.inReplyTo()),
properties: RoomTimelineItemProperties(isEdited: messageTimelineItem.isEdited(),
reactions: aggregateReactions(eventItemProxy.reactions),
deliveryStatus: eventItemProxy.deliveryStatus,
orderedReadReceipts: orderReadReceipts(eventItemProxy.readReceipts),
transactionID: eventItemProxy.transactionID))
}
private func aggregateReactions(_ reactions: [Reaction]) -> [AggregatedReaction] {
reactions.map { reaction in
@@ -334,7 +355,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
source: MediaSourceProxy(source: messageContent.source, mimeType: messageContent.info?.mimetype),
contentType: UTType(mimeType: messageContent.info?.mimetype, fallbackFilename: messageContent.body))
}
private func buildImageTimelineItemContent(_ messageContent: ImageMessageContent) -> ImageRoomTimelineItemContent {
let thumbnailSource = messageContent.info?.thumbnailSource.map { MediaSourceProxy(source: $0, mimeType: messageContent.info?.thumbnailInfo?.mimetype) }
let width = messageContent.info?.width.map(CGFloat.init)
@@ -354,7 +375,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
blurhash: messageContent.info?.blurhash,
contentType: UTType(mimeType: messageContent.info?.mimetype, fallbackFilename: messageContent.body))
}
private func buildVideoTimelineItemContent(_ messageContent: VideoMessageContent) -> VideoRoomTimelineItemContent {
let thumbnailSource = messageContent.info?.thumbnailSource.map { MediaSourceProxy(source: $0, mimeType: messageContent.info?.thumbnailInfo?.mimetype) }
let width = messageContent.info?.width.map(CGFloat.init)
@@ -375,7 +396,11 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
blurhash: messageContent.info?.blurhash,
contentType: UTType(mimeType: messageContent.info?.mimetype, fallbackFilename: messageContent.body))
}
private func buildLocationTimelineItemContent(_ locationContent: LocationContent) -> LocationRoomTimelineItemContent {
LocationRoomTimelineItemContent(body: locationContent.body, geoURI: .init(string: locationContent.geoUri))
}
private func buildFileTimelineItemContent(_ messageContent: FileMessageContent) -> FileRoomTimelineItemContent {
let thumbnailSource = messageContent.info?.thumbnailSource.map { MediaSourceProxy(source: $0, mimeType: messageContent.info?.thumbnailInfo?.mimetype) }
@@ -490,8 +515,8 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
replyContent = .text(buildTextTimelineItemContent(content))
case .video(let content):
replyContent = .video(buildVideoTimelineItemContent(content))
case .location:
return nil
case .location(let content):
replyContent = .location(buildLocationTimelineItemContent(content))
case .none:
return nil
}
@@ -35,6 +35,7 @@ enum RoomTimelineViewProvider: Identifiable, Hashable {
case timelineStart(TimelineStartRoomTimelineItem, TimelineGroupStyle)
case state(StateRoomTimelineItem, TimelineGroupStyle)
case group(CollapsibleTimelineItem, TimelineGroupStyle)
case location(LocationRoomTimelineItem, TimelineGroupStyle)
// swiftlint:disable:next cyclomatic_complexity
init(timelineItem: RoomTimelineItemProtocol, groupStyle: TimelineGroupStyle) {
@@ -73,6 +74,8 @@ enum RoomTimelineViewProvider: Identifiable, Hashable {
self = .state(item, groupStyle)
case let item as CollapsibleTimelineItem:
self = .group(item, groupStyle)
case let item as LocationRoomTimelineItem:
self = .location(item, groupStyle)
default:
fatalError("Unknown timeline item")
}
@@ -96,7 +99,8 @@ enum RoomTimelineViewProvider: Identifiable, Hashable {
.unsupported(let item as RoomTimelineItemProtocol, _),
.timelineStart(let item as RoomTimelineItemProtocol, _),
.state(let item as RoomTimelineItemProtocol, _),
.group(let item as RoomTimelineItemProtocol, _):
.group(let item as RoomTimelineItemProtocol, _),
.location(let item as RoomTimelineItemProtocol, _):
return item.id
}
}
@@ -114,9 +118,14 @@ enum RoomTimelineViewProvider: Identifiable, Hashable {
.encrypted(let item as EventBasedTimelineItemProtocol, _),
.sticker(let item as EventBasedTimelineItemProtocol, _),
.unsupported(let item as EventBasedTimelineItemProtocol, _),
.state(let item as EventBasedTimelineItemProtocol, _):
.state(let item as EventBasedTimelineItemProtocol, _),
.location(let item as EventBasedTimelineItemProtocol, _):
return item.properties.deliveryStatus == .sending || item.properties.deliveryStatus == .sendingFailed
default:
case .separator,
.readMarker,
.paginationIndicator,
.timelineStart,
.group:
return false
}
}
@@ -124,7 +133,7 @@ enum RoomTimelineViewProvider: Identifiable, Hashable {
/// Whether or not it is possible to send a reaction to this timeline item.
var isReactable: Bool {
switch self {
case .text, .image, .video, .audio, .file, .emote, .notice, .sticker:
case .text, .image, .video, .audio, .file, .emote, .notice, .sticker, .location:
return true
case .redacted, .encrypted, .unsupported, .state: // Event based items that aren't reactable
return false
@@ -178,6 +187,8 @@ extension RoomTimelineViewProvider: View {
StateRoomTimelineView(timelineItem: item)
case .group(let item, _):
CollapsibleRoomTimelineView(timelineItem: item)
case .location(let item, _):
LocationRoomTimelineView(timelineItem: item)
}
}
@@ -199,7 +210,8 @@ extension RoomTimelineViewProvider: View {
.unsupported(_, let groupStyle),
.timelineStart(_, let groupStyle),
.state(_, let groupStyle),
.group(_, let groupStyle):
.group(_, let groupStyle),
.location(_, let groupStyle):
return groupStyle
}
}