Private
Public Access
1
0

11 Commits

Author SHA1 Message Date
8304b68a64 first attempt at trying to keep track of locally send id 2025-09-12 12:04:31 -07:00
6261351598 osx: wiring for opening a new window, but not connected to gesture yet
when I add `.tapGesture(count: 2)` to list items, this seems to block
single clicks because SwiftUI sucks. Need to find a better way to invoke
this.
2025-09-11 15:33:56 -07:00
955ff95520 osx: name app "Kordophone" instead of kordophone2 2025-09-11 15:33:31 -07:00
754ad3282d Merge branch 'wip/attachment_mime'
* wip/attachment_mime:
  core: attachment store: limit concurrent downloads
  core: attachment mime: prefer jpg instead of jfif
  wip: attachment MIME
2025-09-10 14:41:36 -07:00
f901077067 osx: some minor fixes 2025-09-10 14:41:24 -07:00
74d1a7f54b osx: try badging icon for unread 2025-09-09 18:54:14 -07:00
4b497aaabc osx: linkify text, enable selection 2025-09-09 15:45:50 -07:00
6caf008a39 osx: update kordophoned binary 2025-09-09 13:40:43 -07:00
d20afef370 kpcli: updates: print error on error 2025-09-09 13:36:35 -07:00
357be5cdf4 core: HTTPClient: update socket should just automatically retry on subsqeuent auth success 2025-09-09 13:33:13 -07:00
4db28222a6 core: HTTPClient: event stream should just automatically retry after auth token 2025-09-09 13:30:53 -07:00
16 changed files with 383 additions and 101 deletions

View File

@@ -0,0 +1,3 @@
-- Drop the alias mapping table
DROP TABLE IF EXISTS `message_aliases`;

View File

@@ -0,0 +1,7 @@
-- Add table to map local (client) IDs to server message GUIDs
CREATE TABLE IF NOT EXISTS `message_aliases` (
`local_id` TEXT NOT NULL PRIMARY KEY,
`server_id` TEXT NOT NULL UNIQUE,
`conversation_id` TEXT NOT NULL
);

View File

@@ -307,8 +307,11 @@ impl<'a> Repository<'a> {
} }
pub fn delete_all_messages(&mut self) -> Result<()> { pub fn delete_all_messages(&mut self) -> Result<()> {
use crate::schema::messages::dsl::*; use crate::schema::messages::dsl as messages_dsl;
diesel::delete(messages).execute(self.connection)?; use crate::schema::message_aliases::dsl as aliases_dsl;
diesel::delete(messages_dsl::messages).execute(self.connection)?;
diesel::delete(aliases_dsl::message_aliases).execute(self.connection)?;
Ok(()) Ok(())
} }
@@ -359,6 +362,57 @@ impl<'a> Repository<'a> {
) )
} }
/// Create or update an alias mapping between a local (client) message id and a server message id.
pub fn set_message_alias(
&mut self,
local_id_in: &str,
server_id_in: &str,
conversation_id_in: &str,
) -> Result<()> {
use crate::schema::message_aliases::dsl::*;
diesel::replace_into(message_aliases)
.values((
local_id.eq(local_id_in),
server_id.eq(server_id_in),
conversation_id.eq(conversation_id_in),
))
.execute(self.connection)?;
Ok(())
}
/// Returns the local id for a given server id, if any.
pub fn get_local_id_for(&mut self, server_id_in: &str) -> Result<Option<String>> {
use crate::schema::message_aliases::dsl::*;
let result = message_aliases
.filter(server_id.eq(server_id_in))
.select(local_id)
.first::<String>(self.connection)
.optional()?;
Ok(result)
}
/// Batch lookup: returns a map server_id -> local_id for the provided server ids.
pub fn get_local_ids_for(
&mut self,
server_ids_in: Vec<String>,
) -> Result<HashMap<String, String>> {
use crate::schema::message_aliases::dsl::*;
if server_ids_in.is_empty() {
return Ok(HashMap::new());
}
let rows: Vec<(String, String)> = message_aliases
.filter(server_id.eq_any(&server_ids_in))
.select((server_id, local_id))
.load::<(String, String)>(self.connection)?;
let mut map = HashMap::new();
for (sid, lid) in rows {
map.insert(sid, lid);
}
Ok(map)
}
/// Update the contact_id for an existing participant record. /// Update the contact_id for an existing participant record.
pub fn update_participant_contact( pub fn update_participant_contact(
&mut self, &mut self,

View File

@@ -44,6 +44,14 @@ diesel::table! {
} }
} }
diesel::table! {
message_aliases (local_id) {
local_id -> Text,
server_id -> Text,
conversation_id -> Text,
}
}
diesel::table! { diesel::table! {
settings (key) { settings (key) {
key -> Text, key -> Text,
@@ -62,5 +70,6 @@ diesel::allow_tables_to_appear_in_same_query!(
conversation_participants, conversation_participants,
messages, messages,
conversation_messages, conversation_messages,
message_aliases,
settings, settings,
); );

View File

@@ -397,6 +397,7 @@ impl<K: AuthenticationStore + Send + Sync> APIInterface for HTTPAPIClient<K> {
let uri = self let uri = self
.uri_for_endpoint(&endpoint, Some(self.websocket_scheme()))?; .uri_for_endpoint(&endpoint, Some(self.websocket_scheme()))?;
loop {
log::debug!("Connecting to websocket: {:?}", uri); log::debug!("Connecting to websocket: {:?}", uri);
let auth = self.auth_store.get_token().await; let auth = self.auth_store.get_token().await;
@@ -425,10 +426,11 @@ impl<K: AuthenticationStore + Send + Sync> APIInterface for HTTPAPIClient<K> {
log::debug!("Websocket request: {:?}", request); log::debug!("Websocket request: {:?}", request);
let mut should_retry = true; // retry once after authenticating.
match connect_async(request).await.map_err(Error::from) { match connect_async(request).await.map_err(Error::from) {
Ok((socket, response)) => { Ok((socket, response)) => {
log::debug!("Websocket connected: {:?}", response.status()); log::debug!("Websocket connected: {:?}", response.status());
Ok(WebsocketEventSocket::new(socket)) break Ok(WebsocketEventSocket::new(socket))
} }
Err(e) => match &e { Err(e) => match &e {
Error::ClientError(ce) => match ce.as_str() { Error::ClientError(ce) => match ce.as_str() {
@@ -439,22 +441,27 @@ impl<K: AuthenticationStore + Send + Sync> APIInterface for HTTPAPIClient<K> {
let new_token = self.authenticate(credentials.clone()).await?; let new_token = self.authenticate(credentials.clone()).await?;
self.auth_store.set_token(new_token.to_string()).await; self.auth_store.set_token(new_token.to_string()).await;
if should_retry {
// try again on the next attempt. // try again on the next attempt.
return Err(Error::Unauthorized); continue;
} else {
break Err(e);
}
} else { } else {
log::error!("Websocket unauthorized, no credentials provided"); log::error!("Websocket unauthorized, no credentials provided");
return Err(Error::ClientError( break Err(Error::ClientError(
"Unauthorized, no credentials provided".into(), "Unauthorized, no credentials provided".into(),
)); ));
} }
} }
_ => Err(e), _ => break Err(e),
}, },
_ => Err(e), _ => break Err(e),
}, },
} }
} }
}
} }
impl<K: AuthenticationStore + Send + Sync> HTTPAPIClient<K> { impl<K: AuthenticationStore + Send + Sync> HTTPAPIClient<K> {

View File

@@ -347,7 +347,16 @@ impl Daemon {
self.database self.database
.lock() .lock()
.await .await
.with_repository(|r| r.insert_message(&conversation_id, message.into())) .with_repository(|r| {
// 1) Insert the server message
r.insert_message(&conversation_id, message.clone().into())?;
// 2) Persist alias local -> server for stable UI ids
r.set_message_alias(
&outgoing_message.guid.to_string(),
&message.id,
&conversation_id,
)
})
.await .await
.unwrap(); .unwrap();
@@ -448,18 +457,38 @@ impl Daemon {
.get(&conversation_id) .get(&conversation_id)
.unwrap_or(&empty_vec); .unwrap_or(&empty_vec);
self.database // Fetch DB messages and an alias map (server_id -> local_id) in one DB access.
let (db_messages, alias_map) = self
.database
.lock() .lock()
.await .await
.with_repository(|r| { .with_repository(|r| {
r.get_messages_for_conversation(&conversation_id) let msgs = r.get_messages_for_conversation(&conversation_id).unwrap();
.unwrap() let ids: Vec<String> = msgs.iter().map(|m| m.id.clone()).collect();
.into_iter() let map = r.get_local_ids_for(ids).unwrap_or_default();
.map(|m| m.into()) // Convert db::Message to daemon::Message (msgs, map)
.chain(outgoing_messages.into_iter().map(|m| m.into()))
.collect()
}) })
.await .await;
// Convert DB messages to daemon model, substituting local_id when an alias exists.
let mut result: Vec<Message> = Vec::with_capacity(
db_messages.len() + outgoing_messages.len(),
);
for m in db_messages.into_iter() {
let server_id = m.id.clone();
let mut dm: Message = m.into();
if let Some(local_id) = alias_map.get(&server_id) {
dm.id = local_id.clone();
}
result.push(dm);
}
// Append pending outgoing messages (these already use local_id)
for om in outgoing_messages.iter() {
result.push(om.into());
}
result
} }
async fn enqueue_outgoing_message( async fn enqueue_outgoing_message(

View File

@@ -143,7 +143,10 @@ impl ClientCli {
println!("Listening for raw updates..."); println!("Listening for raw updates...");
let mut stream = socket.raw_updates().await; let mut stream = socket.raw_updates().await;
while let Some(Ok(update)) = stream.next().await {
loop {
match stream.next().await.unwrap() {
Ok(update) => {
match update { match update {
SocketUpdate::Update(updates) => { SocketUpdate::Update(updates) => {
for update in updates { for update in updates {
@@ -154,6 +157,13 @@ impl ClientCli {
println!("Pong"); println!("Pong");
} }
} }
},
Err(e) => {
println!("Update error: {:?}", e);
break;
}
}
} }
Ok(()) Ok(())

View File

@@ -32,29 +32,29 @@
/* End PBXCopyFilesBuildPhase section */ /* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
CD41F5972E5B8E7300E0027B /* kordophone2.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = kordophone2.app; sourceTree = BUILT_PRODUCTS_DIR; }; CD41F5972E5B8E7300E0027B /* Kordophone.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Kordophone.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
CD41F5DA2E62850100E0027B /* Exceptions for "kordophone2" folder in "kordophone2" target */ = { CD41F5DA2E62850100E0027B /* Exceptions for "kordophone2" folder in "Kordophone" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet; isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = ( membershipExceptions = (
Daemon/kordophoned, Daemon/kordophoned,
Daemon/net.buzzert.kordophonecd.plist, Daemon/net.buzzert.kordophonecd.plist,
); );
target = CD41F5962E5B8E7300E0027B /* kordophone2 */; target = CD41F5962E5B8E7300E0027B /* Kordophone */;
}; };
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet section */ /* Begin PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet section */
CD41F5DC2E62853800E0027B /* Exceptions for "kordophone2" folder in "Copy Files" phase from "kordophone2" target */ = { CD41F5DC2E62853800E0027B /* Exceptions for "kordophone2" folder in "Copy Files" phase from "Kordophone" target */ = {
isa = PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet; isa = PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet;
buildPhase = CD41F5D92E6284FD00E0027B /* CopyFiles */; buildPhase = CD41F5D92E6284FD00E0027B /* CopyFiles */;
membershipExceptions = ( membershipExceptions = (
Daemon/net.buzzert.kordophonecd.plist, Daemon/net.buzzert.kordophonecd.plist,
); );
}; };
CD41F5E12E62860700E0027B /* Exceptions for "kordophone2" folder in "Copy Files" phase from "kordophone2" target */ = { CD41F5E12E62860700E0027B /* Exceptions for "kordophone2" folder in "Copy Files" phase from "Kordophone" target */ = {
isa = PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet; isa = PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet;
attributesByRelativePath = { attributesByRelativePath = {
Daemon/kordophoned = (CodeSignOnCopy, ); Daemon/kordophoned = (CodeSignOnCopy, );
@@ -70,9 +70,9 @@
CD41F5992E5B8E7300E0027B /* kordophone2 */ = { CD41F5992E5B8E7300E0027B /* kordophone2 */ = {
isa = PBXFileSystemSynchronizedRootGroup; isa = PBXFileSystemSynchronizedRootGroup;
exceptions = ( exceptions = (
CD41F5DA2E62850100E0027B /* Exceptions for "kordophone2" folder in "kordophone2" target */, CD41F5DA2E62850100E0027B /* Exceptions for "kordophone2" folder in "Kordophone" target */,
CD41F5DC2E62853800E0027B /* Exceptions for "kordophone2" folder in "Copy Files" phase from "kordophone2" target */, CD41F5DC2E62853800E0027B /* Exceptions for "kordophone2" folder in "Copy Files" phase from "Kordophone" target */,
CD41F5E12E62860700E0027B /* Exceptions for "kordophone2" folder in "Copy Files" phase from "kordophone2" target */, CD41F5E12E62860700E0027B /* Exceptions for "kordophone2" folder in "Copy Files" phase from "Kordophone" target */,
); );
path = kordophone2; path = kordophone2;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -102,7 +102,7 @@
CD41F5982E5B8E7300E0027B /* Products */ = { CD41F5982E5B8E7300E0027B /* Products */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
CD41F5972E5B8E7300E0027B /* kordophone2.app */, CD41F5972E5B8E7300E0027B /* Kordophone.app */,
); );
name = Products; name = Products;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -110,9 +110,9 @@
/* End PBXGroup section */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
CD41F5962E5B8E7300E0027B /* kordophone2 */ = { CD41F5962E5B8E7300E0027B /* Kordophone */ = {
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = CD41F5A32E5B8E7400E0027B /* Build configuration list for PBXNativeTarget "kordophone2" */; buildConfigurationList = CD41F5A32E5B8E7400E0027B /* Build configuration list for PBXNativeTarget "Kordophone" */;
buildPhases = ( buildPhases = (
CD41F5932E5B8E7300E0027B /* Sources */, CD41F5932E5B8E7300E0027B /* Sources */,
CD41F5942E5B8E7300E0027B /* Frameworks */, CD41F5942E5B8E7300E0027B /* Frameworks */,
@@ -127,12 +127,12 @@
fileSystemSynchronizedGroups = ( fileSystemSynchronizedGroups = (
CD41F5992E5B8E7300E0027B /* kordophone2 */, CD41F5992E5B8E7300E0027B /* kordophone2 */,
); );
name = kordophone2; name = Kordophone;
packageProductDependencies = ( packageProductDependencies = (
CD41F5D22E62431D00E0027B /* KeychainAccess */, CD41F5D22E62431D00E0027B /* KeychainAccess */,
); );
productName = kordophone2; productName = kordophone2;
productReference = CD41F5972E5B8E7300E0027B /* kordophone2.app */; productReference = CD41F5972E5B8E7300E0027B /* Kordophone.app */;
productType = "com.apple.product-type.application"; productType = "com.apple.product-type.application";
}; };
/* End PBXNativeTarget section */ /* End PBXNativeTarget section */
@@ -167,7 +167,7 @@
projectDirPath = ""; projectDirPath = "";
projectRoot = ""; projectRoot = "";
targets = ( targets = (
CD41F5962E5B8E7300E0027B /* kordophone2 */, CD41F5962E5B8E7300E0027B /* Kordophone */,
); );
}; };
/* End PBXProject section */ /* End PBXProject section */
@@ -322,7 +322,7 @@
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = DQQH5H6GBD; DEVELOPMENT_TEAM = 3SJALV9BQ7;
ENABLE_HARDENED_RUNTIME = NO; ENABLE_HARDENED_RUNTIME = NO;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -349,7 +349,7 @@
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = DQQH5H6GBD; DEVELOPMENT_TEAM = 3SJALV9BQ7;
ENABLE_HARDENED_RUNTIME = NO; ENABLE_HARDENED_RUNTIME = NO;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -379,7 +379,7 @@
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
CD41F5A32E5B8E7400E0027B /* Build configuration list for PBXNativeTarget "kordophone2" */ = { CD41F5A32E5B8E7400E0027B /* Build configuration list for PBXNativeTarget "Kordophone" */ = {
isa = XCConfigurationList; isa = XCConfigurationList;
buildConfigurations = ( buildConfigurations = (
CD41F5A42E5B8E7400E0027B /* Debug */, CD41F5A42E5B8E7400E0027B /* Debug */,

View File

@@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1640"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CD41F5962E5B8E7300E0027B"
BuildableName = "Kordophone.app"
BlueprintName = "Kordophone"
ReferencedContainer = "container:kordophone2.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CD41F5962E5B8E7300E0027B"
BuildableName = "Kordophone.app"
BlueprintName = "Kordophone"
ReferencedContainer = "container:kordophone2.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CD41F5962E5B8E7300E0027B"
BuildableName = "Kordophone.app"
BlueprintName = "Kordophone"
ReferencedContainer = "container:kordophone2.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -14,6 +14,13 @@ struct KordophoneApp: App
WindowGroup { WindowGroup {
SplitView() SplitView()
} }
.commands {
TextEditingCommands()
}
WindowGroup(id: .transcriptWindow, for: Display.Conversation.self) { selectedConversation in
TranscriptWindowView(conversation: selectedConversation)
}
Settings { Settings {
PreferencesView() PreferencesView()
@@ -25,3 +32,42 @@ struct KordophoneApp: App
print("Error: \(e.localizedDescription)") print("Error: \(e.localizedDescription)")
} }
} }
struct TranscriptWindowView: View
{
@State private var transcriptViewModel = TranscriptView.ViewModel()
@State private var entryViewModel = MessageEntryView.ViewModel()
private let displayedConversation: Binding<Display.Conversation?>
public init(conversation: Binding<Display.Conversation?>) {
self.displayedConversation = conversation
transcriptViewModel.displayedConversation = conversation.wrappedValue
observeDisplayedConversationChanges()
}
private func observeDisplayedConversationChanges() {
withObservationTracking {
_ = displayedConversation.wrappedValue
} onChange: {
Task { @MainActor in
guard let displayedConversation = self.displayedConversation.wrappedValue else { return }
transcriptViewModel.displayedConversation = displayedConversation
observeDisplayedConversationChanges()
}
}
}
var body: some View {
VStack {
ConversationView(transcriptModel: $transcriptViewModel, entryModel: $entryViewModel)
.navigationTitle(displayedConversation.wrappedValue?.displayName ?? "Kordophone")
.selectedConversation(displayedConversation.wrappedValue)
}
}
}
extension String
{
static let transcriptWindow = "TranscriptWindow"
}

View File

@@ -11,9 +11,10 @@ struct ConversationListView: View
{ {
@Binding var model: ViewModel @Binding var model: ViewModel
@Environment(\.xpcClient) private var xpcClient @Environment(\.xpcClient) private var xpcClient
@Environment(\.openWindow) private var openWindow
var body: some View { var body: some View {
List($model.conversations, selection: $model.selectedConversations) { conv in List($model.conversations, selection: $model.selectedConversation) { conv in
let isUnread = conv.wrappedValue.unreadCount > 0 let isUnread = conv.wrappedValue.unreadCount > 0
HStack(spacing: 0.0) { HStack(spacing: 0.0) {
@@ -64,14 +65,14 @@ struct ConversationListView: View
class ViewModel class ViewModel
{ {
var conversations: [Display.Conversation] var conversations: [Display.Conversation]
var selectedConversations: Set<Display.Conversation.ID> var selectedConversation: Display.Conversation.ID?
private var needsReload: Bool = true private var needsReload: Bool = true
private let client = XPCClient() private let client = XPCClient()
public init(conversations: [Display.Conversation] = []) { public init(conversations: [Display.Conversation] = []) {
self.conversations = conversations self.conversations = conversations
self.selectedConversations = Set() self.selectedConversation = nil
setNeedsReload() setNeedsReload()
} }
@@ -101,6 +102,11 @@ struct ConversationListView: View
.map { Display.Conversation(from: $0) } .map { Display.Conversation(from: $0) }
self.conversations = clientConversations self.conversations = clientConversations
let unreadConversations = clientConversations.filter(\.isUnread)
await MainActor.run {
NSApplication.shared.dockTile.badgeLabel = unreadConversations.isEmpty ? nil : "\(unreadConversations.count)"
}
} catch { } catch {
print("Error reloading conversations: \(error)") print("Error reloading conversations: \(error)")
} }

Binary file not shown.

View File

@@ -36,6 +36,7 @@ struct MessageEntryView: View
.font(.body) .font(.body)
.scrollDisabled(true) .scrollDisabled(true)
.disabled(selectedConversation == nil) .disabled(selectedConversation == nil)
.id("messageEntry")
} }
.padding(8.0) .padding(8.0)
.background { .background {

View File

@@ -10,7 +10,7 @@ import XPC
enum Display enum Display
{ {
struct Conversation: Identifiable, Hashable struct Conversation: Identifiable, Hashable, Codable
{ {
let id: String let id: String
let name: String? let name: String?
@@ -27,6 +27,10 @@ enum Display
participants.count > 1 participants.count > 1
} }
var isUnread: Bool {
unreadCount > 0
}
init(from c: Serialized.Conversation) { init(from c: Serialized.Conversation) {
self.id = c.guid self.id = c.guid
self.name = c.displayName self.name = c.displayName

View File

@@ -15,7 +15,7 @@ struct SplitView: View
private let xpcClient = XPCClient() private let xpcClient = XPCClient()
private var selectedConversation: Display.Conversation? { private var selectedConversation: Display.Conversation? {
guard let id = conversationListModel.selectedConversations.first else { return nil } guard let id = conversationListModel.selectedConversation else { return nil }
return conversationListModel.conversations.first { $0.id == id } return conversationListModel.conversations.first { $0.id == id }
} }
@@ -28,10 +28,10 @@ struct SplitView: View
ConversationView(transcriptModel: $transcriptViewModel, entryModel: $entryViewModel) ConversationView(transcriptModel: $transcriptViewModel, entryModel: $entryViewModel)
.xpcClient(xpcClient) .xpcClient(xpcClient)
.selectedConversation(selectedConversation) .selectedConversation(selectedConversation)
.navigationTitle("Kordophone") .navigationTitle(selectedConversation?.displayName ?? "Kordophone")
.navigationSubtitle(selectedConversation?.displayName ?? "") .navigationSubtitle(selectedConversation?.participants.joined(separator: ", ") ?? "")
.onChange(of: conversationListModel.selectedConversations) { oldValue, newValue in .onChange(of: conversationListModel.selectedConversation) { oldValue, newValue in
transcriptViewModel.displayedConversation = conversationListModel.conversations.first { $0.id == newValue.first } transcriptViewModel.displayedConversation = conversationListModel.conversations.first { $0.id == newValue }
} }
} }
} }

View File

@@ -67,7 +67,7 @@ struct TextBubbleItemView: View
BubbleView(sender: sender, date: date) { BubbleView(sender: sender, date: date) {
HStack { HStack {
Text(text) Text(text.linkifiedAttributedString())
.foregroundStyle(textColor) .foregroundStyle(textColor)
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
} }
@@ -75,6 +75,7 @@ struct TextBubbleItemView: View
.padding(.horizontal, 16.0) .padding(.horizontal, 16.0)
.padding(.vertical, 10.0) .padding(.vertical, 10.0)
.background(bubbleColor) .background(bubbleColor)
.textSelection(.enabled)
} }
} }
} }
@@ -219,14 +220,16 @@ struct SenderAttributionView: View
} }
} }
fileprivate extension CGFloat { fileprivate extension CGFloat
{
static let dominantCornerRadius = 16.0 static let dominantCornerRadius = 16.0
static let minorCornerRadius = 4.0 static let minorCornerRadius = 4.0
static let minimumBubbleHorizontalPadding = 80.0 static let minimumBubbleHorizontalPadding = 80.0
static let imageMaxWidth = 380.0 static let imageMaxWidth = 380.0
} }
fileprivate extension CGSize { fileprivate extension CGSize
{
var aspectRatio: CGFloat { width / height } var aspectRatio: CGFloat { width / height }
} }
@@ -239,3 +242,28 @@ fileprivate func preferredBubbleWidth(forAttachmentSize attachmentSize: CGSize?,
return 200.0 // fallback return 200.0 // fallback
} }
} }
fileprivate extension String
{
func linkifiedAttributedString() -> AttributedString {
var attributed = AttributedString(self)
guard let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) else {
return attributed
}
let nsText = self as NSString
let fullRange = NSRange(location: 0, length: nsText.length)
detector.enumerateMatches(in: self, options: [], range: fullRange) { result, _, _ in
guard let result, let url = result.url,
let swiftRange = Range(result.range, in: self),
let start = AttributedString.Index(swiftRange.lowerBound, within: attributed),
let end = AttributedString.Index(swiftRange.upperBound, within: attributed) else { return }
attributed[start..<end].link = url
attributed[start..<end].foregroundColor = NSColor.textColor
attributed[start..<end].underlineStyle = .single
}
return attributed
}
}