Sliding Sync + New Timeline API (#189)

* Begin adopting new Timeline API.

* Add edited indicator and reactions.

* vector-im/element-x-ios/issues/65 - Sliding sync support
* Fix missing room display name, wrong placeholder avatar text color and various other warnings that would fail the build on the CI

* Various tweaks:
* using release version of the demo branch of the sdk
* enabled home screen last room messages
* switched debug mode rust logging to warn
* enabled redactions
* enabled new logout flows and soft logout
* enabled replies

* Fix room member display name and avatar crashes / race condition, fix unit tests
* Make the ClientProxy and the UserSession MainActors
* Remove unused MatrixRustSDK imports, we should strive to keep these only in top level services and proxies
* Don't start either of the syncs while in soft logout

* #181: Style the session verification banner to match Figma.
* #181: Update verification modal.
* #181: Update snapshot tests.

* Make session verification state machine less pedantic
* Remove unnecessary weak selfs
* Various tweaks following code review:
* add start and stop sync client proxy methods
* move ss proxy url the build settings
* made media provider load results discardable
* added publishers for the roomSummaryProvider's total number of rooms and state

* Fix when sender details are shown
* Disable sync v2, causes duplicates in the timeline (as expected)
* Move ClientProxy media loading off the main queue and into a detached task
* Another attempt at moving image loading off the main queue
* Moved home screen diffing and latest room fetching to the background
* Prevent the timeline composer from becoming the first responder when not needed

* Bump to a newer version of the RustSDK

* Fixes vector-im/element-x-ios/issues/107 - New home screen design
* Implement thumbnail loading instead of full image avatars.
* Revert "Disable sync v2, causes duplicates in the timeline (as expected)"
* Add support for local echoes, dispatching detached tasks to a concurrenc GCD queue
* Move the session verification banner to a List Section to avoid UI glitches
* Optimise room mapping after sliding sync updates and thumbnail fetching
* Replace home screen List with a LazyVStack in an attempt to fix performance. Moved move summary provider room updating to a background thread

* Fixes vector-im/element-x-ios/issues/177 - New Bubbles Design
* Define in group state for timeline items
* Add replies into the bubble
* Add timeline width environment value
* Add `RoundedCorner` shape with specific corners rounding
* Add in group state for previews
* Implement bubble grouping logic
* Timeline avatar layout changes
* Fix placeholder avatars for dark mode
* New bubbles design
* Update mock timeline items
* Update timeline separator design
* Update room screen reference screenshots
* Add changelog
* Formatting fixes
* Add some space before single or beginning outgoing items

* Redesign the message composer

* Handle the msgtype enum.

* Update room name label line limit and incoming bubble background. Disabled syncv2, ss withCommonExtensions and session verification controller checking

* Increase default back pagination limit.
* Stop parsing links and tidy up composer button.
* Also fix the frame of an image whilst loading.
* Bump SDK package version.

* Remove app states about settings
* Add strings
* Use colors on placeholder avatars
* Tiny changes for placeholder avatars
* Update settings screen design
* Provide a user display name from the mock client
* Settings screen presentation logic
* Add changelog
* Update reference screenshots

Co-authored-by: Doug <douglase@element.io>
Co-authored-by: ismailgulek <ismailgulek@users.noreply.github.com>
Co-authored-by: ismailgulek <ismailg@matrix.org>
This commit is contained in:
Stefan Ceriu
2022-09-21 11:21:58 +03:00
committed by GitHub
parent a88491be1a
commit 5ebe923991
124 changed files with 2502 additions and 1838 deletions
@@ -14,7 +14,6 @@
// limitations under the License.
//
import MatrixRustSDK
import UIKit
@MainActor
@@ -15,7 +15,6 @@
//
import AppAuth
import MatrixRustSDK
import SwiftUI
struct LoginCoordinatorParameters {
@@ -15,7 +15,6 @@
//
import AppAuth
import MatrixRustSDK
import SwiftUI
struct SoftLogoutCoordinatorParameters {
@@ -20,7 +20,6 @@ import SwiftUI
struct HomeScreenCoordinatorParameters {
let userSession: UserSessionProtocol
let attributedStringBuilder: AttributedStringBuilderProtocol
let memberDetailProviderManager: MemberDetailProviderManager
}
enum HomeScreenCoordinatorAction {
@@ -40,8 +39,6 @@ final class HomeScreenCoordinator: Coordinator, Presentable {
private let hostingController: UIViewController
private var viewModel: HomeScreenViewModelProtocol
private var roomSummaries: [RoomSummary] = []
private var cancellables = Set<AnyCancellable>()
// MARK: Public
@@ -55,7 +52,8 @@ final class HomeScreenCoordinator: Coordinator, Presentable {
init(parameters: HomeScreenCoordinatorParameters) {
self.parameters = parameters
viewModel = HomeScreenViewModel(attributedStringBuilder: parameters.attributedStringBuilder)
viewModel = HomeScreenViewModel(userSession: parameters.userSession,
attributedStringBuilder: parameters.attributedStringBuilder)
let view = HomeScreen(context: viewModel.context)
hostingController = UIHostingController(rootView: view)
@@ -72,42 +70,10 @@ final class HomeScreenCoordinator: Coordinator, Presentable {
self.callback?(.verifySession)
}
}
parameters.userSession.clientProxy
.callbacks
.receive(on: DispatchQueue.main)
.sink { [weak self] callback in
if case .updatedRoomsList = callback {
self?.updateRoomsList()
}
}.store(in: &cancellables)
parameters.userSession.callbacks
.receive(on: DispatchQueue.main)
.sink { [weak self] callback in
switch callback {
case .sessionVerificationNeeded:
self?.viewModel.showSessionVerificationBanner()
case .didVerifySession:
self?.viewModel.hideSessionVerificationBanner()
default:
break
}
}.store(in: &cancellables)
updateRoomsList()
Task {
if case let .success(userAvatarURLString) = await parameters.userSession.clientProxy.loadUserAvatarURLString() {
if case let .success(avatar) = await parameters.userSession.mediaProvider.loadImageFromURLString(userAvatarURLString) {
self.viewModel.updateWithUserAvatar(avatar)
}
}
}
}
// MARK: - Public
func start() { }
func toPresentable() -> UIViewController {
@@ -116,26 +82,6 @@ final class HomeScreenCoordinator: Coordinator, Presentable {
// MARK: - Private
func updateRoomsList() {
roomSummaries = parameters.userSession.clientProxy.rooms.compactMap { roomProxy in
guard roomProxy.isJoined, !roomProxy.isSpace, !roomProxy.isTombstoned else {
return nil
}
if let summary = self.roomSummaries.first(where: { $0.id == roomProxy.id }) {
return summary
}
let memberDetailProvider = parameters.memberDetailProviderManager.memberDetailProviderForRoomProxy(roomProxy)
return RoomSummary(roomProxy: roomProxy,
mediaProvider: parameters.userSession.mediaProvider,
eventBriefFactory: EventBriefFactory(memberDetailProvider: memberDetailProvider))
}
viewModel.updateWithRoomSummaries(roomSummaries)
}
private func processUserMenuAction(_ action: HomeScreenViewUserMenuAction) {
switch action {
case .settings:
@@ -35,6 +35,7 @@ enum HomeScreenViewAction {
case selectRoom(roomIdentifier: String)
case userMenu(action: HomeScreenViewUserMenuAction)
case verifySession
case skipSessionVerification
}
struct HomeScreenViewState: BindableState {
@@ -44,24 +45,16 @@ struct HomeScreenViewState: BindableState {
var rooms: [HomeScreenRoom] = []
var isLoadingRooms = false
var visibleDMs: [HomeScreenRoom] {
searchFilteredRooms.filter(\.isDirect)
var isLoadingRooms: Bool {
rooms.isEmpty
}
var visibleRooms: [HomeScreenRoom] {
searchFilteredRooms.filter { !$0.isDirect }
}
private var searchFilteredRooms: LazyFilterSequence<LazySequence<[HomeScreenRoom]>.Elements> {
guard !bindings.searchQuery.isEmpty else {
// This extra filter is fine for now as there are always downstream filters
// but if that changes, this approach should be reconsidered.
return rooms.lazy.filter { _ in true }
if bindings.searchQuery.isEmpty {
return rooms
}
return rooms.lazy.filter { $0.displayName?.localizedStandardContains(bindings.searchQuery) ?? false }
return rooms.lazy.filter { $0.name.localizedStandardContains(bindings.searchQuery) }
}
var bindings = HomeScreenViewStateBindings()
@@ -74,17 +67,15 @@ struct HomeScreenViewStateBindings {
struct HomeScreenRoom: Identifiable, Equatable {
let id: String
var displayName: String?
let name: String
var topic: String?
var lastMessage: String?
let hasUnreads: Bool
let timestamp: String?
var lastMessage: AttributedString?
var avatar: UIImage?
let isDirect: Bool
let isEncrypted: Bool
let isSpace: Bool
let isTombstoned: Bool
}
extension MutableCollection where Element == HomeScreenRoom {
@@ -20,29 +20,54 @@ import SwiftUI
typealias HomeScreenViewModelType = StateStoreViewModel<HomeScreenViewState, HomeScreenViewAction>
class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol {
private let userSession: UserSessionProtocol
private let roomSummaryProvider: RoomSummaryProviderProtocol
private let attributedStringBuilder: AttributedStringBuilderProtocol
private var roomUpdateListeners = Set<AnyCancellable>()
private var roomsUpdateTask: Task<Void, Never>? {
willSet {
roomsUpdateTask?.cancel()
}
}
private var roomSummaries: [RoomSummaryProtocol]? {
didSet {
state.isLoadingRooms = (roomSummaries?.count ?? 0 == 0)
}
}
var callback: ((HomeScreenViewModelAction) -> Void)?
// MARK: - Setup
init(attributedStringBuilder: AttributedStringBuilderProtocol) {
init(userSession: UserSessionProtocol, attributedStringBuilder: AttributedStringBuilderProtocol) {
self.userSession = userSession
roomSummaryProvider = userSession.clientProxy.roomSummaryProvider
self.attributedStringBuilder = attributedStringBuilder
super.init(initialViewState: HomeScreenViewState(isLoadingRooms: true))
super.init(initialViewState: HomeScreenViewState())
userSession.callbacks
.receive(on: DispatchQueue.main)
.sink { [weak self] callback in
switch callback {
case .sessionVerificationNeeded:
self?.state.showSessionVerificationBanner = true
case .didVerifySession:
self?.state.showSessionVerificationBanner = false
default:
break
}
}.store(in: &cancellables)
roomSummaryProvider.callbacks
.receive(on: DispatchQueue.main)
.sink { [weak self] callback in
switch callback {
case .updatedRoomSummaries:
Task {
await self?.updateRooms()
}
}
}.store(in: &cancellables)
Task {
if case let .success(userAvatarURLString) = await userSession.clientProxy.loadUserAvatarURLString() {
if case let .success(avatar) = await userSession.mediaProvider.loadImageFromURLString(userAvatarURLString, size: MediaProviderDefaultAvatarSize) {
state.userAvatar = avatar
}
}
await updateRooms()
}
}
// MARK: - Public
@@ -50,124 +75,63 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
override func process(viewAction: HomeScreenViewAction) async {
switch viewAction {
case .loadRoomData(let roomIdentifier):
loadRoomDataForIdentifier(roomIdentifier)
loadDataForRoomIdentifier(roomIdentifier)
case .selectRoom(let roomIdentifier):
callback?(.selectRoom(roomIdentifier: roomIdentifier))
case .userMenu(let action):
callback?(.userMenu(action: action))
case .verifySession:
callback?(.verifySession)
case .skipSessionVerification:
state.showSessionVerificationBanner = false
}
}
func updateWithRoomSummaries(_ roomSummaries: [RoomSummaryProtocol]) {
roomsUpdateTask = Task {
await updateWithRoomSummaries(roomSummaries)
}
}
private func updateWithRoomSummaries(_ roomSummaries: [RoomSummaryProtocol]) async {
var rooms = [HomeScreenRoom]()
for summary in roomSummaries {
if Task.isCancelled {
return
}
rooms.append(await buildOrUpdateRoomForSummary(summary))
}
if Task.isCancelled {
return
}
state.rooms = rooms
self.roomSummaries = roomSummaries
roomUpdateListeners.removeAll()
roomSummaries.forEach { roomSummary in
roomSummary.callbacks
.receive(on: DispatchQueue.main)
.sink { [weak self] callback in
guard let self = self else {
return
}
Task {
if let index = self.state.rooms.firstIndex(where: { $0.id == roomSummary.id }) {
switch callback {
case .updatedLastMessage:
var room = self.state.rooms[index]
room.lastMessage = await self.lastMessageFromEventBrief(roomSummary.lastMessage)
self.state.rooms[index] = room
default:
self.state.rooms[index] = await self.buildOrUpdateRoomForSummary(roomSummary)
}
}
}
}
.store(in: &roomUpdateListeners)
}
}
func updateWithUserAvatar(_ avatar: UIImage) {
state.userAvatar = avatar
}
func showSessionVerificationBanner() {
state.showSessionVerificationBanner = true
}
func hideSessionVerificationBanner() {
state.showSessionVerificationBanner = false
}
// MARK: - Private
private func loadRoomDataForIdentifier(_ roomIdentifier: String) {
guard let roomSummary = roomSummaries?.first(where: { $0.id == roomIdentifier }) else {
MXLog.error("Invalid room identifier")
private func loadDataForRoomIdentifier(_ identifier: String) {
guard let summary = roomSummaryProvider.roomSummaries.first(where: { $0.id == identifier }),
var room = state.rooms.first(where: { $0.id == identifier }) else {
return
}
Task { await roomSummary.loadDetails() }
}
private func buildOrUpdateRoomForSummary(_ roomSummary: RoomSummaryProtocol) async -> HomeScreenRoom {
guard var room = state.rooms.first(where: { $0.id == roomSummary.id }) else {
return HomeScreenRoom(id: roomSummary.id,
displayName: roomSummary.displayName,
topic: roomSummary.topic,
lastMessage: await lastMessageFromEventBrief(roomSummary.lastMessage),
avatar: roomSummary.avatar,
isDirect: roomSummary.isDirect,
isEncrypted: roomSummary.isEncrypted,
isSpace: roomSummary.isSpace,
isTombstoned: roomSummary.isTombstoned)
if room.avatar != nil {
return
}
room.avatar = roomSummary.avatar
room.displayName = roomSummary.displayName
room.topic = roomSummary.topic
if let avatarURLString = summary.avatarURLString {
Task {
if case let .success(image) = await userSession.mediaProvider.loadImageFromURLString(avatarURLString, size: MediaProviderDefaultAvatarSize) {
if let index = state.rooms.firstIndex(of: room) {
room.avatar = image
state.rooms[index] = room
}
}
}
}
}
private func updateRooms() async {
state.rooms = await Task.detached {
var rooms = [HomeScreenRoom]()
for summary in self.roomSummaryProvider.roomSummaries {
let avatarImage = await self.userSession.mediaProvider.imageFromURLString(summary.avatarURLString, size: MediaProviderDefaultAvatarSize)
return room
}
private func lastMessageFromEventBrief(_ eventBrief: EventBrief?) async -> String? {
guard let eventBrief = eventBrief else {
return nil
}
let senderDisplayName = senderDisplayNameForBrief(eventBrief)
if let htmlBody = eventBrief.htmlBody,
let lastMessageAttributedString = await attributedStringBuilder.fromHTML(htmlBody) {
return "\(senderDisplayName): \(String(lastMessageAttributedString.characters))"
} else {
return "\(senderDisplayName): \(eventBrief.body)"
}
}
private func senderDisplayNameForBrief(_ brief: EventBrief) -> String {
brief.senderDisplayName ?? brief.senderId
var timestamp: String?
if let lastMessageTimestamp = summary.lastMessageTimestamp {
timestamp = lastMessageTimestamp.formatted(date: .omitted, time: .shortened)
}
rooms.append(HomeScreenRoom(id: summary.id,
name: summary.name,
hasUnreads: summary.unreadNotificationCount > 0,
timestamp: timestamp,
lastMessage: summary.lastMessage,
avatar: avatarImage))
}
return rooms
}.value
}
}
@@ -22,10 +22,4 @@ protocol HomeScreenViewModelProtocol {
var callback: ((HomeScreenViewModelAction) -> Void)? { get set }
var context: HomeScreenViewModelType.Context { get }
func updateWithUserAvatar(_ avatar: UIImage)
func updateWithRoomSummaries(_ roomSummaries: [RoomSummaryProtocol])
func showSessionVerificationBanner()
func hideSessionVerificationBanner()
}
@@ -29,36 +29,20 @@ struct HomeScreen: View {
ProgressView()
}
} else {
if context.viewState.showSessionVerificationBanner {
HStack {
Text(ElementL10n.verificationVerifyDevice)
Spacer()
Button(ElementL10n.startVerification) {
context.send(viewAction: .verifySession)
}
}
.padding()
.background(Color.element.quaternaryContent)
.padding(.top, 1)
}
List {
Section(ElementL10n.rooms) {
ForEach(context.viewState.visibleRooms) { room in
RoomCell(room: room, context: context)
.listRowBackground(Color.clear)
}
ScrollView {
if context.viewState.showSessionVerificationBanner {
sessionVerificationBanner
}
Section(ElementL10n.bottomActionPeople) {
ForEach(context.viewState.visibleDMs) { room in
RoomCell(room: room, context: context)
.listRowBackground(Color.clear)
LazyVStack {
ForEach(context.viewState.visibleRooms) { room in
HomeScreenRoomCell(room: room, context: context)
}
}
.searchable(text: $context.searchQuery)
.animation(.default, value: context.viewState.visibleRooms)
.padding(.horizontal)
}
.listStyle(.plain)
.searchable(text: $context.searchQuery)
}
Spacer()
@@ -66,7 +50,7 @@ struct HomeScreen: View {
.transition(.slide)
.animation(.elementDefault, value: context.viewState.showSessionVerificationBanner)
.ignoresSafeArea(.all, edges: .bottom)
.navigationBarTitleDisplayMode(.inline)
.navigationTitle(ElementL10n.homeScreenAllChats)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
userMenuButton
@@ -119,6 +103,37 @@ struct HomeScreen: View {
return .empty
}
}
private var sessionVerificationBanner: some View {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 2) {
Text(ElementL10n.sessionVerificationBannerTitle)
.font(.element.subheadlineBold)
.foregroundColor(.element.systemPrimaryLabel)
Text(ElementL10n.sessionVerificationBannerMessage)
.font(.element.footnote)
.foregroundColor(.element.systemSecondaryLabel)
}
HStack(spacing: 16) {
Button(ElementL10n.actionSkip) {
context.send(viewAction: .skipSessionVerification)
}
.frame(maxWidth: .infinity)
.buttonStyle(.elementCapsule)
Button(ElementL10n.continue) {
context.send(viewAction: .verifySession)
}
.frame(maxWidth: .infinity)
.buttonStyle(.elementCapsuleProminent)
}
}
.padding(16)
.background(Color.element.systemSecondaryBackground)
.cornerRadius(14)
.padding(.horizontal, 16)
}
private func settings() {
context.send(viewAction: .userMenu(action: .settings))
@@ -137,62 +152,6 @@ struct HomeScreen: View {
}
}
struct RoomCell: View {
@ScaledMetric private var avatarSize = 32.0
let room: HomeScreenRoom
let context: HomeScreenViewModel.Context
var body: some View {
Button {
context.send(viewAction: .selectRoom(roomIdentifier: room.id))
} label: {
HStack(spacing: 16.0) {
if let avatar = room.avatar {
Image(uiImage: avatar)
.resizable()
.scaledToFill()
.frame(width: avatarSize, height: avatarSize)
.clipShape(Circle())
} else {
PlaceholderAvatarImage(text: room.displayName ?? room.id)
.clipShape(Circle())
.frame(width: avatarSize, height: avatarSize)
}
VStack(alignment: .leading, spacing: 2.0) {
Text(roomName(room))
.foregroundStyle(.primary)
if let roomTopic = room.topic, roomTopic.count > 0 {
Text(roomTopic)
.font(.footnote.weight(.semibold))
.lineLimit(1)
.foregroundStyle(.secondary)
}
if let lastMessage = room.lastMessage {
Text(lastMessage)
.font(.callout)
.lineLimit(1)
.foregroundStyle(.secondary)
.padding(.top, 2)
}
}
}
.animation(.elementDefault, value: room)
.frame(minHeight: 60.0)
.task {
context.send(viewAction: .loadRoomData(roomIdentifier: room.id))
}
}
}
private func roomName(_ room: HomeScreenRoom) -> String {
room.displayName ?? room.id + (room.isEncrypted ? "🛡" : "")
}
}
// MARK: - Previews
struct HomeScreen_Previews: PreviewProvider {
@@ -202,28 +161,13 @@ struct HomeScreen_Previews: PreviewProvider {
body.preferredColorScheme(.dark)
.tint(.element.accent)
}
static var body: some View {
let viewModel = HomeScreenViewModel(attributedStringBuilder: AttributedStringBuilder())
let userSession = MockUserSession(clientProxy: MockClientProxy(userIdentifier: "John Doe"),
mediaProvider: MockMediaProvider())
let eventBrief = EventBrief(eventId: "id",
senderId: "senderId",
senderDisplayName: "Sender",
body: "Some message",
htmlBody: nil,
date: .now)
let roomSummaries = [MockRoomSummary(displayName: "Alpha", topic: "Topic"),
MockRoomSummary(displayName: "Beta"),
MockRoomSummary(displayName: "Omega", lastMessage: eventBrief)]
viewModel.updateWithRoomSummaries(roomSummaries)
if let avatarImage = UIImage(systemName: "person.fill") {
viewModel.updateWithUserAvatar(avatarImage)
}
viewModel.showSessionVerificationBanner()
let viewModel = HomeScreenViewModel(userSession: userSession,
attributedStringBuilder: AttributedStringBuilder())
return NavigationView {
HomeScreen(context: viewModel.context)
@@ -0,0 +1,123 @@
//
// Copyright 2022 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 HomeScreenRoomCell: View {
@ScaledMetric private var avatarSize = 44.0
let room: HomeScreenRoom
let context: HomeScreenViewModel.Context
var body: some View {
Button {
context.send(viewAction: .selectRoom(roomIdentifier: room.id))
} label: {
HStack(spacing: 16.0) {
if let avatar = room.avatar {
Image(uiImage: avatar)
.resizable()
.scaledToFill()
.frame(width: avatarSize, height: avatarSize)
.clipShape(Circle())
} else {
PlaceholderAvatarImage(text: room.name, contentId: room.id)
.clipShape(Circle())
.frame(width: avatarSize, height: avatarSize)
}
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 2.0) {
Text(room.name)
.font(.element.callout.bold())
.foregroundColor(.element.primaryContent)
.lineLimit(1)
if let lastMessage = room.lastMessage {
Text(lastMessage)
.font(lastMessageFont)
.foregroundColor(lastMessageForegroundColor)
.lineLimit(2)
.multilineTextAlignment(.leading)
.padding(.top, 2)
.animation(nil, value: UUID()) // Text animations look ugly
}
}
Spacer()
VStack(alignment: .trailing, spacing: 3.0) {
if let timestamp = room.timestamp {
Text(timestamp)
.font(.element.caption1)
.foregroundColor(.element.secondaryContent)
}
if room.hasUnreads {
Rectangle()
.frame(width: 12, height: 12)
.foregroundColor(.element.primaryContent)
.clipShape(Circle())
}
}
}
}
.frame(minHeight: 64.0)
.task {
context.send(viewAction: .loadRoomData(roomIdentifier: room.id))
}
}
}
var lastMessageFont: Font {
if room.hasUnreads {
return .element.subheadline.bold()
} else {
return .element.subheadline
}
}
var lastMessageForegroundColor: Color {
if room.hasUnreads {
return .element.primaryContent
} else {
return .element.secondaryContent
}
}
}
struct HomeScreenRoomCell_Previews: PreviewProvider {
static var previews: some View {
body.preferredColorScheme(.light)
.tint(.element.accent)
body.preferredColorScheme(.dark)
.tint(.element.accent)
}
static var body: some View {
let userSession = MockUserSession(clientProxy: MockClientProxy(userIdentifier: "John Doe"),
mediaProvider: MockMediaProvider())
let viewModel = HomeScreenViewModel(userSession: userSession,
attributedStringBuilder: AttributedStringBuilder())
return VStack {
ForEach(viewModel.context.viewState.rooms) { room in
HomeScreenRoomCell(room: room, context: viewModel.context)
}
}
}
}
@@ -20,7 +20,6 @@ struct RoomScreenCoordinatorParameters {
let timelineController: RoomTimelineControllerProtocol
let roomName: String?
let roomAvatar: UIImage?
let roomEncryptionBadge: UIImage?
}
final class RoomScreenCoordinator: Coordinator, Presentable {
@@ -45,8 +44,7 @@ final class RoomScreenCoordinator: Coordinator, Presentable {
let viewModel = RoomScreenViewModel(timelineController: parameters.timelineController,
timelineViewFactory: RoomTimelineViewFactory(),
roomName: parameters.roomName,
roomAvatar: parameters.roomAvatar,
roomEncryptionBadge: parameters.roomEncryptionBadge)
roomAvatar: parameters.roomAvatar)
let view = RoomScreen(context: viewModel.context)
roomScreenViewModel = viewModel
@@ -43,6 +43,7 @@ enum RoomScreenViewAction {
}
struct RoomScreenViewState: BindableState {
var roomId: String
var roomTitle = ""
var roomAvatar: UIImage?
var roomEncryptionBadge: UIImage?
@@ -54,7 +55,6 @@ struct RoomScreenViewState: BindableState {
var composerMode: RoomScreenComposerMode = .default
var messageComposerDisabled = false // Remove this when we have local echoes
var sendButtonDisabled: Bool {
bindings.composerText.count == 0
}
@@ -20,7 +20,7 @@ typealias RoomScreenViewModelType = StateStoreViewModel<RoomScreenViewState, Roo
class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol {
private enum Constants {
static let backPaginationPageSize: UInt = 30
static let backPaginationPageSize: UInt = 50
}
private let timelineController: RoomTimelineControllerProtocol
@@ -36,7 +36,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
self.timelineController = timelineController
self.timelineViewFactory = timelineViewFactory
super.init(initialViewState: RoomScreenViewState(roomTitle: roomName ?? "Unknown room 💥",
super.init(initialViewState: RoomScreenViewState(roomId: timelineController.roomId,
roomTitle: roomName ?? "Unknown room 💥",
roomAvatar: roomAvatar,
roomEncryptionBadge: roomEncryptionBadge,
bindings: .init(composerText: "", composerFocused: false)))
@@ -106,19 +107,17 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
fatalError("This message should never be empty")
}
state.messageComposerDisabled = true
switch state.composerMode {
case .reply(let itemId, _):
await timelineController.sendReply(state.bindings.composerText, to: itemId)
default:
await timelineController.sendMessage(state.bindings.composerText)
}
let message = state.bindings.composerText
state.bindings.composerText = ""
state.composerMode = .default
state.messageComposerDisabled = false
switch state.composerMode {
case .reply(let itemId, _):
await timelineController.sendReply(message, to: itemId)
default:
await timelineController.sendMessage(message)
}
}
private func displayError(_ type: RoomScreenErrorType) {
@@ -144,14 +143,13 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
return []
}
let actions: [TimelineItemContextMenuAction] = [
var actions: [TimelineItemContextMenuAction] = [
.copy, .quote, .copyPermalink, .reply
]
#warning("Outgoing actions to be handled with the new Timeline API.")
// if timelineItem.isOutgoing {
// actions.append(.redact)
// }
if let item = timelineItem as? EventBasedTimelineItemProtocol, item.isOutgoing {
actions.append(.redact)
}
return actions
}
@@ -26,46 +26,48 @@ struct MessageComposer: View {
let replyCancellationAction: () -> Void
var body: some View {
HStack(alignment: .bottom) {
let rect = RoundedRectangle(cornerRadius: 8.0)
VStack(alignment: .leading, spacing: 2.0) {
if case let .reply(_, displayName) = type {
MessageComposerReplyHeader(displayName: displayName, action: replyCancellationAction)
}
let rect = RoundedRectangle(cornerRadius: borderRadius)
VStack(alignment: .leading, spacing: 4.0) {
if case let .reply(_, displayName) = type {
MessageComposerReplyHeader(displayName: displayName, action: replyCancellationAction)
}
HStack(alignment: .center) {
MessageComposerTextField(placeholder: "Send a message",
text: $text,
focused: $focused,
maxHeight: 300)
Button {
sendAction()
} label: {
Image(systemName: "paperplane")
.font(.element.title3)
.foregroundColor(sendingDisabled ? .element.tempActionBackground : .element.tempActionForeground)
.padding(8.0)
.background(
Circle()
.foregroundColor(sendingDisabled ? .clear : .element.tempActionBackground)
)
}
.disabled(sendingDisabled)
.animation(.elementDefault, value: sendingDisabled)
.keyboardShortcut(.return, modifiers: [.command])
.padding(4.0)
}
.padding(4.0)
.frame(minHeight: 44.0)
.clipShape(rect)
.overlay(rect.stroke(borderColor, lineWidth: borderWidth))
.animation(.elementDefault, value: type)
.animation(.elementDefault, value: borderWidth)
Button {
sendAction()
} label: {
Image(uiImage: Asset.Images.timelineComposerSendMessage.image)
.background(Circle()
.foregroundColor(.global.white)
)
}
.padding(.bottom, 6.0)
.disabled(sendingDisabled)
.opacity(sendingDisabled ? 0.5 : 1.0)
.animation(.elementDefault, value: sendingDisabled)
.keyboardShortcut(.return, modifiers: [.command])
}
.padding(.leading, 12.0)
.background(.thinMaterial)
.clipShape(rect)
.animation(.elementDefault, value: type)
}
private var borderColor: Color {
.element.accent
}
private var borderWidth: CGFloat {
focused ? 2.0 : 1.0
private var borderRadius: CGFloat {
switch type {
case .default:
return 28.0
case .reply:
return 12.0
}
}
}
@@ -83,8 +85,9 @@ private struct MessageComposerReplyHeader: View {
Button {
action()
} label: {
Image(systemName: "xmark")
.font(.element.caption2)
Image(systemName: "x.circle")
.font(.element.callout)
.foregroundColor(.element.secondaryContent)
.padding(4.0)
}
}
@@ -71,6 +71,7 @@ private struct UITextViewWrapper: UIViewRepresentable {
let textView = UITextView()
textView.delegate = context.coordinator
textView.textColor = .element.primaryContent
textView.isEditable = true
textView.font = UIFont.preferredFont(forTextStyle: .body)
textView.isSelectable = true
@@ -92,9 +93,8 @@ private struct UITextViewWrapper: UIViewRepresentable {
UITextViewWrapper.recalculateHeight(view: textView, result: $calculatedHeight, maxHeight: maxHeight)
if focused, textView.window != nil, !textView.isFirstResponder {
// Avoid cycle detected through attribute warnings
DispatchQueue.main.async {
DispatchQueue.main.async { // Avoid cycle detected through attribute warnings
if focused, textView.window != nil, !textView.isFirstResponder {
textView.becomeFirstResponder()
}
}
@@ -52,7 +52,8 @@ struct RoomHeaderView: View {
.scaledToFill()
.accessibilityIdentifier("roomAvatarImage")
} else {
PlaceholderAvatarImage(text: context.viewState.roomTitle)
PlaceholderAvatarImage(text: context.viewState.roomTitle,
contentId: context.viewState.roomId)
.accessibilityIdentifier("roomAvatarPlaceholderImage")
}
}
@@ -33,7 +33,6 @@ struct RoomScreen: View {
context.send(viewAction: .cancelReply)
}
.padding()
.opacity(context.viewState.messageComposerDisabled ? 0.5 : 1.0)
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
@@ -24,25 +24,30 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
@ViewBuilder let content: () -> Content
@Environment(\.colorScheme) private var colorScheme
@ScaledMetric private var minBubbleWidth = 44
@Environment(\.timelineWidth) private var timelineWidth
@ScaledMetric private var senderNameVerticalPadding = 3
private let bubbleWidthPercentIncoming = 0.72 // 281/390
private let bubbleWidthPercentOutgoing = 0.68 // 267/390
var body: some View {
VStack(alignment: alignment, spacing: -5) {
VStack(alignment: alignment, spacing: -12) {
if !timelineItem.isOutgoing {
header
.zIndex(1)
}
if timelineItem.isOutgoing {
HStack {
Spacer()
styledContentWithReactions
}
.padding(.trailing, 16)
.padding(.leading, 51)
} else {
styledContentWithReactions
VStack(alignment: alignment) {
if timelineItem.isOutgoing {
HStack {
Spacer()
styledContentWithReactions
}
.padding(.trailing, 16)
.padding(.leading, 16)
.padding(.trailing, 51)
} else {
styledContentWithReactions
.padding(.leading, 24)
.padding(.trailing, 24)
}
}
}
}
@@ -56,10 +61,10 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
HStack(alignment: .top, spacing: 4) {
TimelineSenderAvatarView(timelineItem: timelineItem)
Text(timelineItem.senderDisplayName ?? timelineItem.senderId)
.font(.body)
.font(.element.footnoteBold)
.foregroundColor(.element.primaryContent)
.fontWeight(.semibold)
.lineLimit(1)
.padding(.vertical, senderNameVerticalPadding)
}
}
}
@@ -73,9 +78,10 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
if !timelineItem.properties.reactions.isEmpty {
TimelineReactionsView(reactions: timelineItem.properties.reactions,
alignment: alignment) { key in
alignment: .leading) { key in
context.send(viewAction: .sendReaction(key: key, eventID: timelineItem.id))
}
.frame(width: bubbleWidth - 24)
.padding(.horizontal, 12)
}
}
@@ -83,24 +89,27 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
@ViewBuilder
var styledContent: some View {
if timelineItem.isOutgoing {
styledContentOutgoing
} else {
styledContentIncoming
}
}
@ViewBuilder
var styledContentOutgoing: some View {
if timelineItem.inGroupState == .single || timelineItem.inGroupState == .beginning {
Spacer()
.frame(height: 8)
}
if shouldAvoidBubbling {
ZStack(alignment: .bottomTrailing) {
content()
.clipped()
.cornerRadius(8)
Text(timelineItem.timestamp)
.foregroundColor(.global.white)
.font(.element.caption2)
.padding(4)
.background(Color(white: 0, opacity: 0.7))
.clipped()
.cornerRadius(8)
.offset(x: -8, y: -8)
}
content()
.frame(width: bubbleWidth)
.cornerRadius(12, inGroupState: timelineItem.inGroupState)
} else {
VStack(alignment: .trailing, spacing: 4) {
content()
.frame(minWidth: minBubbleWidth, alignment: .leading)
.frame(width: bubbleWidth - 24, alignment: .leading)
if timelineItem.properties.isEdited {
Text(ElementL10n.editedSuffix)
@@ -108,25 +117,50 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
.foregroundColor(.element.tertiaryContent)
}
}
.padding(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8))
.clipped()
.background(bubbleColor)
.cornerRadius(12)
.padding(EdgeInsets(top: 6, leading: 12, bottom: 6, trailing: 12))
.background(Color.element.systemGray5)
.cornerRadius(12, inGroupState: timelineItem.inGroupState)
}
}
@ViewBuilder
var styledContentIncoming: some View {
if shouldAvoidBubbling {
content()
.frame(width: bubbleWidth)
.cornerRadius(12, inGroupState: timelineItem.inGroupState)
} else {
VStack(alignment: .trailing, spacing: 4) {
content()
.frame(width: bubbleWidth - 24, alignment: .leading)
if timelineItem.properties.isEdited {
Text(ElementL10n.editedSuffix)
.font(.element.caption2)
.foregroundColor(.element.tertiaryContent)
}
}
.padding(EdgeInsets(top: 6, leading: 12, bottom: 6, trailing: 12))
.background(Color.element.systemGray6) // Demo time!
.cornerRadius(12, inGroupState: timelineItem.inGroupState) // Demo time!
// .overlay(
// RoundedCorner(radius: 18, inGroupState: timelineItem.inGroupState)
// .stroke(Color.element.systemGray5)
// )
}
}
private var shouldAvoidBubbling: Bool {
timelineItem is ImageRoomTimelineItem
}
private var bubbleColor: Color {
let opacity = colorScheme == .light ? 0.06 : 0.15
return timelineItem.isOutgoing ? .element.accent.opacity(opacity) : .element.system
}
private var alignment: HorizontalAlignment {
timelineItem.isOutgoing ? .trailing : .leading
}
private var bubbleWidth: CGFloat {
timelineWidth * (timelineItem.isOutgoing ? bubbleWidthPercentOutgoing : bubbleWidthPercentIncoming)
}
}
struct TimelineItemBubbledStylerView_Previews: PreviewProvider {
@@ -144,6 +178,7 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider {
}
}
.timelineStyle(.bubbles)
.timelineWidth(390)
.padding(.horizontal, 8)
.previewLayout(.sizeThatFits)
}
@@ -50,3 +50,22 @@ extension View {
environment(\.timelineStyle, style)
}
}
// MARK: - Timeline Width
private struct TimelineWidthKey: EnvironmentKey {
static let defaultValue: CGFloat = 0
}
extension EnvironmentValues {
var timelineWidth: CGFloat {
get { self[TimelineWidthKey.self] }
set { self[TimelineWidthKey.self] = newValue }
}
}
extension View {
func timelineWidth(_ width: CGFloat) -> some View {
environment(\.timelineWidth, width)
}
}
@@ -25,9 +25,9 @@ struct EmoteRoomTimelineView: View {
HStack(alignment: .top) {
Image(systemName: "face.dashed").padding(.top, 1.0)
if let attributedComponents = timelineItem.attributedComponents {
FormattedBodyText(attributedComponents: attributedComponents)
FormattedBodyText(isOutgoing: timelineItem.isOutgoing, attributedComponents: attributedComponents)
} else {
FormattedBodyText(text: timelineItem.text)
FormattedBodyText(isOutgoing: timelineItem.isOutgoing, text: timelineItem.text)
}
}
}
@@ -59,6 +59,7 @@ struct EmoteRoomTimelineView_Previews: PreviewProvider {
text: text,
timestamp: timestamp,
shouldShowSenderDetails: true,
inGroupState: .single,
isOutgoing: false,
senderId: senderId)
}
@@ -18,19 +18,32 @@ import Foundation
import SwiftUI
struct FormattedBodyText: View {
#warning("this is a dirty fix for demo, should be refactored after new timeline api")
let isOutgoing: Bool
let attributedComponents: [AttributedStringBuilderComponent]
var body: some View {
VStack(alignment: .leading, spacing: 8.0) {
ForEach(attributedComponents, id: \.self) { component in
if component.isBlockquote {
HStack(spacing: 4.0) {
Rectangle()
.foregroundColor(Color.red)
.frame(width: 4.0)
if isOutgoing {
Text(component.attributedString)
.fixedSize(horizontal: false, vertical: true)
.foregroundColor(.element.primaryContent)
.padding(EdgeInsets(top: 6, leading: 12, bottom: 6, trailing: 12))
.clipped()
.background(Color.element.systemGray4)
.cornerRadius(13)
} else {
Text(component.attributedString)
.fixedSize(horizontal: false, vertical: true)
.foregroundColor(.element.primaryContent)
.padding(EdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12))
.clipped()
.overlay(
RoundedRectangle(cornerRadius: 13)
.stroke(Color.element.systemGray5)
)
}
} else {
Text(component.attributedString)
@@ -44,7 +57,8 @@ struct FormattedBodyText: View {
}
extension FormattedBodyText {
init(text: String) {
init(isOutgoing: Bool, text: String) {
self.isOutgoing = isOutgoing
attributedComponents = [.init(attributedString: AttributedString(text), isBlockquote: false)]
}
}
@@ -86,11 +100,11 @@ struct FormattedBodyText_Previews: PreviewProvider {
let attributedString = attributedStringBuilder.fromHTML(htmlString)
if let components = attributedStringBuilder.blockquoteCoalescedComponentsFrom(attributedString) {
FormattedBodyText(attributedComponents: components)
FormattedBodyText(isOutgoing: true, attributedComponents: components)
.fixedSize()
}
}
FormattedBodyText(text: "Some plain text that's not an attributed component.")
FormattedBodyText(isOutgoing: true, text: "Some plain text that's not an attributed component.")
}
}
}
@@ -21,39 +21,32 @@ struct ImageRoomTimelineView: View {
let timelineItem: ImageRoomTimelineItem
var body: some View {
if timelineItem.image != nil || timelineItem.blurhash != nil { // Fixes view heights after loading finishes
TimelineStyler(timelineItem: timelineItem) {
if let image = timelineItem.image {
if let aspectRatio = timelineItem.aspectRatio {
Image(uiImage: image)
.resizable()
.aspectRatio(aspectRatio, contentMode: .fit)
} else {
Image(uiImage: image)
.resizable()
.scaledToFit()
}
} else if let blurhash = timelineItem.blurhash,
// Build a small blurhash image so that it's fast
let image = UIImage(blurHash: blurhash, size: .init(width: 10.0, height: 10.0)) {
Image(uiImage: image)
.resizable()
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
}
}
.id(timelineItem.id)
.animation(.elementDefault, value: timelineItem.image)
.frame(maxHeight: 1000.0)
} else {
TimelineStyler(timelineItem: timelineItem) {
HStack {
Spacer()
TimelineStyler(timelineItem: timelineItem) {
if let image = timelineItem.image {
Image(uiImage: image)
.resizable()
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
} else if let blurhash = timelineItem.blurhash,
// Build a small blurhash image so that it's fast
let image = UIImage(blurHash: blurhash, size: .init(width: 10.0, height: 10.0)) {
Image(uiImage: image)
.resizable()
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
} else {
ZStack {
Rectangle()
.foregroundColor(.element.systemGray6)
.opacity(0.3)
ProgressView("Loading")
Spacer()
.frame(maxWidth: .infinity)
}
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
}
.id(timelineItem.id)
}
.id(timelineItem.id)
.animation(.elementDefault, value: timelineItem.image)
.frame(maxHeight: 1000.0)
}
}
@@ -70,6 +63,7 @@ struct ImageRoomTimelineView_Previews: PreviewProvider {
text: "Some image",
timestamp: "Now",
shouldShowSenderDetails: false,
inGroupState: .single,
isOutgoing: false,
senderId: "Bob",
source: nil,
@@ -79,6 +73,7 @@ struct ImageRoomTimelineView_Previews: PreviewProvider {
text: "Some other image",
timestamp: "Now",
shouldShowSenderDetails: false,
inGroupState: .single,
isOutgoing: false,
senderId: "Bob",
source: nil,
@@ -88,6 +83,7 @@ struct ImageRoomTimelineView_Previews: PreviewProvider {
text: "Blurhashed image",
timestamp: "Now",
shouldShowSenderDetails: false,
inGroupState: .single,
isOutgoing: false,
senderId: "Bob",
source: nil,
@@ -25,9 +25,9 @@ struct NoticeRoomTimelineView: View {
HStack(alignment: .top) {
Image(systemName: "exclamationmark.bubble").padding(.top, 2.0)
if let attributedComponents = timelineItem.attributedComponents {
FormattedBodyText(attributedComponents: attributedComponents)
FormattedBodyText(isOutgoing: timelineItem.isOutgoing, attributedComponents: attributedComponents)
} else {
FormattedBodyText(text: timelineItem.text)
FormattedBodyText(isOutgoing: timelineItem.isOutgoing, text: timelineItem.text)
}
}
}
@@ -60,6 +60,7 @@ struct NoticeRoomTimelineView_Previews: PreviewProvider {
text: text,
timestamp: timestamp,
shouldShowSenderDetails: true,
inGroupState: .single,
isOutgoing: false,
senderId: senderId)
}
@@ -18,22 +18,32 @@ import SwiftUI
struct PlaceholderAvatarImage: View {
private let textForImage: String
private let contentId: String?
var body: some View {
ZStack {
Color.element.accent
bgColor
Text(textForImage)
.padding(4)
.foregroundColor(.white)
// Make the text resizable (i.e. Make it large and then allow it to scale down)
.font(.system(size: 200).weight(.semibold))
.minimumScaleFactor(0.001)
.foregroundColor(.element.background)
.font(.title2.bold())
}
.aspectRatio(1, contentMode: .fill)
}
init(text: String) {
init(text: String, contentId: String? = nil) {
textForImage = text.first?.uppercased() ?? ""
self.contentId = contentId
}
private var bgColor: Color {
guard let contentId = contentId else {
return .element.accent
}
let colors = Color.element.contentAndAvatars
let colorIndex = Int(contentId.hashCode % Int32(colors.count))
return Color.element.contentAndAvatars[colorIndex % colors.count]
}
}
@@ -45,7 +55,7 @@ struct PlaceholderAvatarImage_Previews: PreviewProvider {
@ViewBuilder
static var body: some View {
PlaceholderAvatarImage(text: "X")
PlaceholderAvatarImage(text: "X", contentId: "@userid:matrix.org")
.clipShape(Circle())
.frame(width: 150, height: 100)
}
@@ -21,33 +21,12 @@ struct SeparatorRoomTimelineView: View {
let timelineItem: SeparatorRoomTimelineItem
var body: some View {
LabelledDivider(label: timelineItem.text)
Text(timelineItem.text)
.font(.element.footnote)
.foregroundColor(.element.secondaryContent)
.id(timelineItem.id)
.padding(.vertical, 8)
}
}
struct LabelledDivider: View {
let label: String
let color: Color
init(label: String, color: Color = Color.element.secondaryContent) {
self.label = label
self.color = color
}
var body: some View {
HStack {
line
Text(label)
.foregroundColor(color)
.fixedSize()
line
}
}
var line: some View {
VStack { Divider().background(color) }
.frame(maxWidth: .infinity)
}
}
@@ -23,9 +23,9 @@ struct TextRoomTimelineView: View {
var body: some View {
TimelineStyler(timelineItem: timelineItem) {
if let attributedComponents = timelineItem.attributedComponents {
FormattedBodyText(attributedComponents: attributedComponents)
FormattedBodyText(isOutgoing: timelineItem.isOutgoing, attributedComponents: attributedComponents)
} else {
FormattedBodyText(text: timelineItem.text)
FormattedBodyText(isOutgoing: timelineItem.isOutgoing, text: timelineItem.text)
}
}
.id(timelineItem.id)
@@ -75,6 +75,7 @@ struct TextRoomTimelineView_Previews: PreviewProvider {
text: text,
timestamp: timestamp,
shouldShowSenderDetails: shouldShowSenderDetails,
inGroupState: .single,
isOutgoing: isOutgoing,
senderId: senderId)
}
@@ -28,26 +28,26 @@ struct TimelineItemList: View {
let bottomVisiblePublisher: PassthroughSubject<Bool, Never>
let scrollToBottomPublisher: PassthroughSubject<Void, Never>
@State private var viewFrame: CGRect = .zero
var body: some View {
// The observer behaves differently when not in an reader
ScrollViewReader { _ in
List {
HStack {
Spacer()
ProgressView()
.opacity(context.viewState.isBackPaginating ? 1.0 : 0.0)
.animation(.elementDefault, value: context.viewState.isBackPaginating)
Spacer()
}
.listRowBackground(Color.clear)
ProgressView()
.frame(maxWidth: .infinity)
.opacity(context.viewState.isBackPaginating ? 1.0 : 0.0)
.animation(.elementDefault, value: context.viewState.isBackPaginating)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
// No idea why previews don't work otherwise
ForEach(isPreview ? context.viewState.items : timelineItems) { timelineItem in
timelineItem
.contextMenu(menuItems: {
.contextMenu {
context.viewState.contextMenuBuilder?(timelineItem.id)
})
}
.opacity(opacityForItem(timelineItem))
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
@@ -65,6 +65,8 @@ struct TimelineItemList: View {
}
}
.listStyle(.plain)
.background(ViewFrameReader(frame: $viewFrame))
.environment(\.timelineWidth, viewFrame.width)
.timelineStyle(settings.timelineStyle)
.environment(\.defaultMinListRowHeight, 0.0)
.introspectTableView { tableView in
@@ -81,24 +83,24 @@ struct TimelineItemList: View {
// Check if there are enough items. Otherwise ask for more
attemptBackPagination()
}
.onAppear(perform: {
.onAppear {
if timelineItems != context.viewState.items {
timelineItems = context.viewState.items
}
})
.onReceive(scrollToBottomPublisher, perform: {
}
.onReceive(scrollToBottomPublisher) {
tableViewObserver.scrollToBottom(animated: true)
})
.onReceive(tableViewObserver.scrollViewTopVisiblePublisher, perform: { isTopVisible in
}
.onReceive(tableViewObserver.scrollViewTopVisiblePublisher) { isTopVisible in
if !isTopVisible || context.viewState.isBackPaginating {
return
}
attemptBackPagination()
})
.onReceive(tableViewObserver.scrollViewBottomVisiblePublisher, perform: { isBottomVisible in
}
.onReceive(tableViewObserver.scrollViewBottomVisiblePublisher) { isBottomVisible in
bottomVisiblePublisher.send(isBottomVisible)
})
}
.onChange(of: context.viewState.items) { _ in
// Don't update the list while moving
if tableViewObserver.isDecelerating || tableViewObserver.isTracking {
@@ -109,7 +111,7 @@ struct TimelineItemList: View {
tableViewObserver.saveCurrentOffset()
timelineItems = context.viewState.items
}
.onReceive(tableViewObserver.scrollViewDidRestPublisher, perform: {
.onReceive(tableViewObserver.scrollViewDidRestPublisher) {
if hasPendingChanges == false {
return
}
@@ -117,13 +119,13 @@ struct TimelineItemList: View {
tableViewObserver.saveCurrentOffset()
timelineItems = context.viewState.items
hasPendingChanges = false
})
.onChange(of: timelineItems, perform: { _ in
}
.onChange(of: timelineItems) { _ in
tableViewObserver.restoreSavedOffset()
// Check if there are enough items. Otherwise ask for more
attemptBackPagination()
})
}
}
}
@@ -20,7 +20,7 @@ import SwiftUI
struct TimelineSenderAvatarView: View {
let timelineItem: EventBasedTimelineItemProtocol
@ScaledMetric private var avatarSize = 26
@ScaledMetric private var avatarSize = 32
var body: some View {
ZStack(alignment: .center) {
@@ -30,14 +30,15 @@ struct TimelineSenderAvatarView: View {
.scaledToFill()
.overlay(Circle().stroke(Color.element.accent))
} else {
PlaceholderAvatarImage(text: timelineItem.senderDisplayName ?? timelineItem.senderId)
PlaceholderAvatarImage(text: timelineItem.senderDisplayName ?? timelineItem.senderId,
contentId: timelineItem.senderId)
}
}
.clipShape(Circle())
.frame(width: avatarSize, height: avatarSize)
.overlay(
Circle()
.stroke(Color.element.background, lineWidth: 2)
.stroke(Color.element.background, lineWidth: 3)
)
.animation(.elementDefault, value: timelineItem.senderAvatar)
@@ -27,12 +27,34 @@ enum SessionVerificationViewModelAction {
struct SessionVerificationViewState: BindableState {
var verificationState: SessionVerificationStateMachine.State = .initial
var shouldDisableDismissButton: Bool {
verificationState != .verified
var title: String? {
switch verificationState {
case .showingChallenge:
return ElementL10n.sessionVerificationScreenEmojisTitle
default:
return nil
}
}
var shouldDisableCancelButton: Bool {
verificationState == .verified
var message: String {
switch verificationState {
case .initial:
return ElementL10n.verificationOpenOtherToVerify
case .requestingVerification:
return ElementL10n.verificationRequestWaiting
case .acceptingChallenge:
return ElementL10n.verificationRequestWaiting
case .decliningChallenge:
return ElementL10n.verificationRequestWaiting
case .cancelling:
return ElementL10n.verificationRequestWaiting
case .showingChallenge:
return ElementL10n.sessionVerificationScreenEmojisMessage
case .verified:
return ElementL10n.verificationConclusionOkSelfNotice
case .cancelled:
return ElementL10n.verificationCancelled
}
}
}
@@ -41,6 +63,5 @@ enum SessionVerificationViewAction {
case restart
case accept
case decline
case dismiss
case cancel
case close
}
@@ -66,22 +66,10 @@ class SessionVerificationStateMachine {
stateMachine.state
}
// swiftlint:disable cyclomatic_complexity
init() {
stateMachine = StateMachine(state: .initial) { machine in
machine.addRoutes(event: .requestVerification, transitions: [.initial => .requestingVerification])
machine.addRoutes(event: .didFail, transitions: [.requestingVerification => .initial])
machine.addRoutes(event: .cancel, transitions: [.requestingVerification => .cancelling])
machine.addRoutes(event: .didCancel, transitions: [.requestingVerification => .cancelled])
// Cancellation request from the other party should either take us from `.cancelling`
// to `.cancelled` or keep us in `.cancelled` if already there. There is more `.didCancel`
// handling in `addRouteMapping` for states containing associated values
machine.addRoutes(event: .didCancel, transitions: [.cancelling => .cancelled])
machine.addRoutes(event: .didCancel, transitions: [.cancelled => .cancelled])
machine.addRoutes(event: .didFail, transitions: [.cancelled => .cancelled])
machine.addRoutes(event: .restart, transitions: [.cancelled => .initial])
// Transitions with associated values need to be handled through `addRouteMapping`
@@ -103,18 +91,9 @@ class SessionVerificationStateMachine {
case (.didFail, .decliningChallenge(let emojis)):
return .showingChallenge(emojis: emojis)
case (.cancel, .showingChallenge):
case (.cancel, _):
return .cancelling
case (.cancel, .acceptingChallenge):
return .cancelling
case (.cancel, .decliningChallenge):
return .cancelling
case (.didCancel, .showingChallenge):
return .cancelled
case (.didCancel, .acceptingChallenge):
return .cancelled
case (.didCancel, .decliningChallenge):
case (.didCancel, _):
return .cancelled
default:
@@ -123,8 +102,6 @@ class SessionVerificationStateMachine {
}
}
}
// swiftlint:enable cyclomatic_complexity
/// Attempt to move the state machine to another state through an event
/// It will either invoke the `transitionHandler` or the `errorHandler` depending on its current state
@@ -74,9 +74,7 @@ class SessionVerificationViewModel: SessionVerificationViewModelType, SessionVer
stateMachine.processEvent(.requestVerification)
case .restart:
stateMachine.processEvent(.restart)
case .dismiss:
callback?(.finished)
case .cancel:
case .close:
guard stateMachine.state == .initial ||
stateMachine.state == .verified ||
stateMachine.state == .cancelled else {
@@ -115,7 +113,7 @@ class SessionVerificationViewModel: SessionVerificationViewModelType, SessionVer
}
stateMachine.addErrorHandler { context in
fatalError("Failed transition with context: \(context)")
MXLog.error("Failed transition with context: \(context)")
}
}
@@ -14,7 +14,6 @@
// limitations under the License.
//
import MatrixRustSDK
import SwiftUI
struct SessionVerificationScreen: View {
@@ -24,127 +23,132 @@ struct SessionVerificationScreen: View {
var body: some View {
NavigationView {
VStack(spacing: 32.0) {
Text(heading)
.font(.body)
.multilineTextAlignment(.center)
.foregroundColor(.element.primaryContent)
.accessibilityIdentifier("titleLabel")
switch context.viewState.verificationState {
case .initial:
StateIcon(systemName: "lock.shield")
Button(ElementL10n.startVerification) {
context.send(viewAction: .start)
}
.buttonStyle(.elementAction(.regular))
.accessibilityIdentifier("startButton")
case .cancelled:
StateIcon(systemName: "xmark.shield")
.accessibilityIdentifier("sessionVerificationFailedIcon")
Button(ElementL10n.globalRetry) {
context.send(viewAction: .restart)
}
.buttonStyle(.elementAction(.regular))
.accessibilityIdentifier("restartButton")
case .requestingVerification:
ProgressView()
.accessibilityIdentifier("requestingVerificationProgressView")
case .cancelling:
ProgressView()
.accessibilityIdentifier("cancellingVerificationProgressView")
case .acceptingChallenge:
ProgressView()
.accessibilityIdentifier("acceptingChallengeProgressView")
case .decliningChallenge:
ProgressView()
.accessibilityIdentifier("decliningChallengeProgressView")
case .showingChallenge(let emojis):
HStack(spacing: 8.0) {
ForEach(emojis.prefix(4), id: \.self) { emoji in
EmojiView(emoji: emoji)
}
}
HStack(spacing: 8.0) {
ForEach(emojis.suffix(from: 4), id: \.self) { emoji in
EmojiView(emoji: emoji)
}
ScrollView {
VStack(spacing: 32.0) {
if let title = context.viewState.title {
Text(title)
.font(.element.headlineBold)
.foregroundColor(.element.systemPrimaryLabel)
.multilineTextAlignment(.center)
}
actionButtons
case .verified:
StateIcon(systemName: "checkmark.shield")
.accessibilityIdentifier("sessionVerificationSucceededIcon")
}
Spacer()
}
.padding()
.padding(.top, 64)
.navigationBarTitleDisplayMode(.inline)
.navigationTitle(ElementL10n.verificationVerifyDevice)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button(ElementL10n.done) {
context.send(viewAction: .dismiss)
}
.disabled(context.viewState.shouldDisableDismissButton)
.accessibilityIdentifier("dismissButton")
}
ToolbarItem(placement: .cancellationAction) {
Button(ElementL10n.actionCancel) {
context.send(viewAction: .cancel)
}
.disabled(context.viewState.shouldDisableCancelButton)
.accessibilityIdentifier("cancelButton")
Text(context.viewState.message)
.font(.body)
.multilineTextAlignment(.center)
.foregroundColor(.element.systemPrimaryLabel)
.accessibilityIdentifier("titleLabel")
mainContent
}
.padding()
.padding(.top, 64)
.frame(maxWidth: .infinity)
.navigationBarTitleDisplayMode(.inline)
.navigationTitle(ElementL10n.verificationProfileVerify)
.toolbar { toolbarContent }
}
.background(Color.element.systemSecondaryBackground)
.safeAreaInset(edge: .bottom) { actionButtons.padding() }
}
.navigationViewStyle(StackNavigationViewStyle())
.navigationViewStyle(.stack)
}
// MARK: - Private
private var heading: String {
@ViewBuilder
private var mainContent: some View {
switch context.viewState.verificationState {
case .initial:
return ElementL10n.verificationOpenOtherToVerify
case .requestingVerification:
return ElementL10n.verificationRequestWaiting
case .acceptingChallenge:
return ElementL10n.verificationRequestWaiting
case .decliningChallenge:
return ElementL10n.verificationRequestWaiting
case .cancelling:
return ElementL10n.verificationRequestWaiting
case .showingChallenge:
return ElementL10n.verificationEmojiNotice
case .verified:
return ElementL10n.verificationConclusionOkSelfNotice
StateIcon(systemName: "lock.shield")
case .cancelled:
return ElementL10n.verificationCancelled
StateIcon(systemName: "xmark.shield")
.accessibilityIdentifier("sessionVerificationFailedIcon")
case .requestingVerification:
ProgressView()
.accessibilityIdentifier("requestingVerificationProgressView")
case .cancelling:
ProgressView()
.accessibilityIdentifier("cancellingVerificationProgressView")
case .acceptingChallenge:
ProgressView()
.accessibilityIdentifier("acceptingChallengeProgressView")
case .decliningChallenge:
ProgressView()
.accessibilityIdentifier("decliningChallengeProgressView")
case .showingChallenge(let emojis):
HStack(spacing: 16) {
ForEach(emojis.prefix(4), id: \.self) { emoji in
EmojiView(emoji: emoji)
}
}
HStack(spacing: 16) {
ForEach(emojis.suffix(from: 4), id: \.self) { emoji in
EmojiView(emoji: emoji)
}
}
case .verified:
StateIcon(systemName: "checkmark.shield")
.accessibilityIdentifier("sessionVerificationSucceededIcon")
}
}
@ViewBuilder
private var actionButtons: some View {
HStack(spacing: 16.0) {
Button(ElementL10n.verificationSasDoNotMatch) {
context.send(viewAction: .decline)
switch context.viewState.verificationState {
case .initial:
Button(ElementL10n.startVerification) {
context.send(viewAction: .start)
}
.buttonStyle(.elementAction(.regular, color: .red))
.accessibilityLabel("challengeDeclineButton")
Button(ElementL10n.verificationSasMatch) {
context.send(viewAction: .accept)
.buttonStyle(.elementAction(.xLarge))
.accessibilityIdentifier("startButton")
case .cancelled:
Button(ElementL10n.globalRetry) {
context.send(viewAction: .restart)
}
.buttonStyle(.elementAction(.regular))
.accessibilityLabel("challengeAcceptButton")
.buttonStyle(.elementAction(.xLarge))
.accessibilityIdentifier("restartButton")
case .showingChallenge:
VStack(spacing: 30) {
Button { context.send(viewAction: .accept) } label: {
Label(ElementL10n.actionMatch, systemImage: "checkmark")
}
.buttonStyle(.elementAction(.xLarge))
.accessibilityLabel("challengeAcceptButton")
Button(ElementL10n.no) {
context.send(viewAction: .decline)
}
.font(.element.bodyBold)
.accessibilityLabel("challengeDeclineButton")
}
case .verified:
Button(ElementL10n.finish) {
context.send(viewAction: .close)
}
.buttonStyle(.elementAction(.xLarge))
.accessibilityIdentifier("finishButton")
default:
EmptyView()
}
}
@ToolbarContentBuilder
private var toolbarContent: some ToolbarContent {
ToolbarItem(placement: .cancellationAction) {
Button { context.send(viewAction: .close) } label: {
Image(systemName: "xmark")
}
.font(.element.bodyBold)
.foregroundColor(.element.systemSecondaryLabel)
.accessibilityIdentifier("closeButton")
}
.padding(32.0)
}
struct EmojiView: View {
@@ -153,9 +157,10 @@ struct SessionVerificationScreen: View {
var body: some View {
VStack(spacing: 16.0) {
Text(emoji.symbol)
.font(.largeTitle)
.font(.element.largeTitleBold)
Text(emoji.description)
.font(.body)
.font(.element.caption2)
.foregroundColor(.element.systemSecondaryLabel)
}
.padding(8.0)
}
@@ -167,6 +172,7 @@ struct SessionVerificationScreen: View {
var body: some View {
Image(systemName: systemName)
.resizable()
.font(.element.body.weight(.light))
.scaledToFit()
.foregroundColor(.element.accent)
.frame(width: 100, height: 100)
@@ -181,23 +187,24 @@ struct SessionVerification_Previews: PreviewProvider {
body.preferredColorScheme(.light)
body.preferredColorScheme(.dark)
}
@ViewBuilder
static var body: some View {
Group {
sessionVerificationScreen(state: .initial)
sessionVerificationScreen(state: .requestingVerification)
sessionVerificationScreen(state: .cancelled)
sessionVerificationScreen(state: .showingChallenge(emojis: MockSessionVerificationControllerProxy.emojis))
sessionVerificationScreen(state: .verified)
}
.tint(Color.element.accent)
}
static func sessionVerificationScreen(state: SessionVerificationStateMachine.State) -> some View {
let viewModel = SessionVerificationViewModel(sessionVerificationControllerProxy: MockSessionVerificationControllerProxy(),
initialState: SessionVerificationViewState(verificationState: state))
return SessionVerificationScreen(context: viewModel.context)
}
}
@@ -23,6 +23,7 @@ struct SettingsCoordinatorParameters {
}
enum SettingsCoordinatorAction {
case dismiss
case logout
}
@@ -38,6 +39,8 @@ final class SettingsCoordinator: Coordinator, Presentable {
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
private var loadingIndicator: UserIndicator?
private var statusIndicator: UserIndicator?
private var navigationRouter: NavigationRouterType { parameters.navigationRouter }
// MARK: Public
@@ -50,7 +53,7 @@ final class SettingsCoordinator: Coordinator, Presentable {
init(parameters: SettingsCoordinatorParameters) {
self.parameters = parameters
let viewModel = SettingsViewModel()
let viewModel = SettingsViewModel(withUserSession: parameters.userSession)
let view = SettingsScreen(context: viewModel.context)
settingsViewModel = viewModel
settingsHostingController = UIHostingController(rootView: view)
@@ -61,6 +64,8 @@ final class SettingsCoordinator: Coordinator, Presentable {
guard let self = self else { return }
MXLog.debug("SettingsViewModel did complete with result: \(result).")
switch result {
case .close:
self.callback?(.dismiss)
case .toggleAnalytics:
self.toggleAnalytics()
case .reportBug:
@@ -68,7 +73,7 @@ final class SettingsCoordinator: Coordinator, Presentable {
case .crash:
self.parameters.bugReportService.crash()
case .logout:
self.callback?(.logout)
self.confirmSignOut()
}
}
}
@@ -106,12 +111,25 @@ final class SettingsCoordinator: Coordinator, Presentable {
add(childCoordinator: coordinator)
coordinator.start()
parameters.navigationRouter.push(coordinator, animated: true) { [weak self] in
navigationRouter.push(coordinator, animated: true) { [weak self] in
guard let self = self else { return }
self.remove(childCoordinator: coordinator)
}
}
private func confirmSignOut() {
let alert = UIAlertController(title: ElementL10n.actionSignOut,
message: ElementL10n.actionSignOutConfirmationSimple,
preferredStyle: .alert)
alert.addAction(UIAlertAction(title: ElementL10n.actionCancel, style: .cancel))
alert.addAction(UIAlertAction(title: ElementL10n.actionSignOut, style: .destructive) { [weak self] _ in
self?.callback?(.logout)
})
navigationRouter.present(alert, animated: true)
}
/// Show an activity indicator whilst loading.
/// - Parameters:
@@ -15,12 +15,14 @@
//
import Foundation
import UIKit
// MARK: - Coordinator
// MARK: View model
enum SettingsViewModelAction {
case close
case toggleAnalytics
case reportBug
case crash
@@ -31,6 +33,9 @@ enum SettingsViewModelAction {
struct SettingsViewState: BindableState {
var bindings: SettingsViewStateBindings
var userID: String
var userAvatar: UIImage?
var userDisplayName: String?
}
struct SettingsViewStateBindings {
@@ -38,6 +43,7 @@ struct SettingsViewStateBindings {
}
enum SettingsViewAction {
case close
case toggleAnalytics
case reportBug
case crash
@@ -25,21 +25,38 @@ class SettingsViewModel: SettingsViewModelType, SettingsViewModelProtocol {
// MARK: Private
private let userSession: UserSessionProtocol
// MARK: Public
var callback: ((SettingsViewModelAction) -> Void)?
// MARK: - Setup
init() {
init(withUserSession userSession: UserSessionProtocol) {
self.userSession = userSession
let bindings = SettingsViewStateBindings()
super.init(initialViewState: .init(bindings: bindings))
super.init(initialViewState: .init(bindings: bindings, userID: userSession.userID))
Task {
if case let .success(userAvatarURLString) = await userSession.clientProxy.loadUserAvatarURLString() {
if case let .success(avatar) = await userSession.mediaProvider.loadImageFromURLString(userAvatarURLString, size: MediaProviderDefaultAvatarSize) {
state.userAvatar = avatar
}
}
if case let .success(userDisplayName) = await self.userSession.clientProxy.loadUserDisplayName() {
state.userDisplayName = userDisplayName
}
}
}
// MARK: - Public
override func process(viewAction: SettingsViewAction) async {
switch viewAction {
case .close:
callback?(.close)
case .toggleAnalytics:
callback?(.toggleAnalytics)
case .reportBug:
@@ -19,9 +19,12 @@ import SwiftUI
struct SettingsScreen: View {
// MARK: Private
@State private var showingLogoutConfirmation = false
@Environment(\.colorScheme) private var colorScheme
@ObservedObject private var settings = ElementSettings.shared
@ScaledMetric private var avatarSize = 60.0
@ScaledMetric private var menuIconSize = 30.0
private let listRowInsets = EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)
// MARK: Public
@@ -31,6 +34,12 @@ struct SettingsScreen: View {
var body: some View {
Form {
userSection
.listRowBackground(rowBackgroundColor)
appearanceSection
.listRowBackground(rowBackgroundColor)
analyticsSection
.listRowBackground(rowBackgroundColor)
@@ -44,7 +53,13 @@ struct SettingsScreen: View {
tableView.backgroundColor = .clear
}
.navigationTitle(ElementL10n.settings)
.navigationBarTitleDisplayMode(.inline)
.background(backgroundColor, ignoresSafeAreaEdges: .all)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
closeButton
}
}
}
private var versionText: some View {
@@ -52,25 +67,85 @@ struct SettingsScreen: View {
}
private var backgroundColor: Color {
colorScheme == .light ? .element.system : .element.background
.element.systemGray6
}
private var rowBackgroundColor: Color {
colorScheme == .light ? .element.background : .element.system
}
private var userSection: some View {
Section {
HStack(spacing: 13) {
userAvatar
VStack(alignment: .leading, spacing: 4) {
Text(context.viewState.userDisplayName ?? "")
.font(.title3)
Text(context.viewState.userID)
.font(.subheadline)
}
}
.listRowInsets(listRowInsets)
}
}
private var appearanceSection: some View {
Section {
Button(action: appearance) {
HStack {
Image(systemName: "paintpalette")
.foregroundColor(.element.systemGray)
.padding(4)
.background(Color.element.systemGray6)
.clipShape(Circle())
.frame(width: menuIconSize, height: menuIconSize)
Text(ElementL10n.settingsAppearance)
Spacer()
Image(systemName: "chevron.forward")
.foregroundColor(.element.tertiaryContent)
}
}
.listRowInsets(listRowInsets)
.foregroundColor(.element.primaryContent)
.accessibilityIdentifier("appearanceButton")
}
}
@ViewBuilder
private var userAvatar: some View {
if let avatar = context.viewState.userAvatar {
Image(uiImage: avatar)
.resizable()
.scaledToFill()
.frame(width: avatarSize, height: avatarSize)
.clipShape(Circle())
} else {
PlaceholderAvatarImage(text: context.viewState.userDisplayName ?? context.viewState.userID, contentId: context.viewState.userID)
.clipShape(Circle())
.frame(width: avatarSize, height: avatarSize)
}
}
private var analyticsSection: some View {
Section(ElementL10n.settingsAnalytics) {
Section {
Button { context.send(viewAction: .reportBug) } label: {
Text(ElementL10n.sendBugReport)
HStack {
Text(ElementL10n.sendBugReport)
Spacer()
Image(systemName: "chevron.forward")
.foregroundColor(.element.tertiaryContent)
}
}
.listRowInsets(listRowInsets)
.listRowSeparator(.hidden)
.foregroundColor(.element.primaryContent)
.accessibilityIdentifier("reportBugButton")
if BuildSettings.settingsCrashButtonVisible {
Button("Crash the app",
Button("Crash app",
role: .destructive) { context.send(viewAction: .crash)
}
.listRowInsets(listRowInsets)
.accessibilityIdentifier("crashButton")
}
}
@@ -79,13 +154,14 @@ struct SettingsScreen: View {
@ViewBuilder
private var userInterfaceSection: some View {
if BuildSettings.settingsShowTimelineStyle {
Section(ElementL10n.settingsUserInterface) {
Section {
Picker(ElementL10n.settingsTimelineStyle, selection: $settings.timelineStyle) {
ForEach(TimelineStyle.allCases, id: \.self) { style in
Text(style.description)
.tag(style)
}
}
.listRowInsets(listRowInsets)
.accessibilityIdentifier("timelineStylePicker")
}
}
@@ -93,23 +169,52 @@ struct SettingsScreen: View {
private var logoutSection: some View {
Section {
Button { showingLogoutConfirmation = true } label: {
Text(ElementL10n.actionSignOut)
}
.frame(maxWidth: .infinity)
.foregroundColor(.element.primaryContent)
.accessibilityIdentifier("logoutButton")
.confirmationDialog(ElementL10n.actionSignOutConfirmationSimple,
isPresented: $showingLogoutConfirmation,
titleVisibility: .visible) {
Button(ElementL10n.actionSignOut,
role: .destructive) { context.send(viewAction: .logout)
Button(action: logout) {
HStack {
Image(systemName: "rectangle.portrait.and.arrow.right")
.foregroundColor(.element.systemGray)
.padding(4)
.background(Color.element.systemGray6)
.clipShape(Circle())
.frame(width: menuIconSize, height: menuIconSize)
Text(ElementL10n.actionSignOut)
Spacer()
Image(systemName: "chevron.forward")
.foregroundColor(.element.tertiaryContent)
}
}
.listRowInsets(listRowInsets)
.foregroundColor(.element.primaryContent)
.accessibilityIdentifier("logoutButton")
} footer: {
versionText
.frame(maxWidth: .infinity)
}
}
private var closeButton: some View {
Button(action: close) {
HStack {
Image(systemName: "xmark")
.font(.title3.bold())
.foregroundColor(.element.secondaryContent)
.padding(4)
}
}
.accessibilityIdentifier("closeButton")
}
private func appearance() {
#warning("Not implemented")
}
private func close() {
context.send(viewAction: .close)
}
private func logout() {
context.send(viewAction: .logout)
}
}
extension TimelineStyle: CustomStringConvertible {
@@ -133,8 +238,13 @@ struct Settings_Previews: PreviewProvider {
@ViewBuilder
static var body: some View {
let viewModel = SettingsViewModel()
SettingsScreen(context: viewModel.context)
.previewInterfaceOrientation(.portrait)
let userSession = MockUserSession(clientProxy: MockClientProxy(userIdentifier: "@userid:example.com"),
mediaProvider: MockMediaProvider())
let viewModel = SettingsViewModel(withUserSession: userSession)
return NavigationView {
SettingsScreen(context: viewModel.context)
.previewInterfaceOrientation(.portrait)
}
}
}