Compare commits
11 Commits
wip/attach
...
wip/local_
| Author | SHA1 | Date | |
|---|---|---|---|
| 8304b68a64 | |||
| 6261351598 | |||
| 955ff95520 | |||
| 754ad3282d | |||
| f901077067 | |||
| 74d1a7f54b | |||
| 4b497aaabc | |||
| 6caf008a39 | |||
| d20afef370 | |||
| 357be5cdf4 | |||
| 4db28222a6 |
@@ -0,0 +1,3 @@
|
||||
-- Drop the alias mapping table
|
||||
DROP TABLE IF EXISTS `message_aliases`;
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -307,8 +307,11 @@ impl<'a> Repository<'a> {
|
||||
}
|
||||
|
||||
pub fn delete_all_messages(&mut self) -> Result<()> {
|
||||
use crate::schema::messages::dsl::*;
|
||||
diesel::delete(messages).execute(self.connection)?;
|
||||
use crate::schema::messages::dsl as messages_dsl;
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
pub fn update_participant_contact(
|
||||
&mut self,
|
||||
|
||||
@@ -44,6 +44,14 @@ diesel::table! {
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
message_aliases (local_id) {
|
||||
local_id -> Text,
|
||||
server_id -> Text,
|
||||
conversation_id -> Text,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
settings (key) {
|
||||
key -> Text,
|
||||
@@ -62,5 +70,6 @@ diesel::allow_tables_to_appear_in_same_query!(
|
||||
conversation_participants,
|
||||
messages,
|
||||
conversation_messages,
|
||||
message_aliases,
|
||||
settings,
|
||||
);
|
||||
|
||||
@@ -397,6 +397,7 @@ impl<K: AuthenticationStore + Send + Sync> APIInterface for HTTPAPIClient<K> {
|
||||
let uri = self
|
||||
.uri_for_endpoint(&endpoint, Some(self.websocket_scheme()))?;
|
||||
|
||||
loop {
|
||||
log::debug!("Connecting to websocket: {:?}", uri);
|
||||
|
||||
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);
|
||||
|
||||
let mut should_retry = true; // retry once after authenticating.
|
||||
match connect_async(request).await.map_err(Error::from) {
|
||||
Ok((socket, response)) => {
|
||||
log::debug!("Websocket connected: {:?}", response.status());
|
||||
Ok(WebsocketEventSocket::new(socket))
|
||||
break Ok(WebsocketEventSocket::new(socket))
|
||||
}
|
||||
Err(e) => match &e {
|
||||
Error::ClientError(ce) => match ce.as_str() {
|
||||
@@ -439,23 +441,28 @@ impl<K: AuthenticationStore + Send + Sync> APIInterface for HTTPAPIClient<K> {
|
||||
let new_token = self.authenticate(credentials.clone()).await?;
|
||||
self.auth_store.set_token(new_token.to_string()).await;
|
||||
|
||||
if should_retry {
|
||||
// try again on the next attempt.
|
||||
return Err(Error::Unauthorized);
|
||||
continue;
|
||||
} else {
|
||||
break Err(e);
|
||||
}
|
||||
} else {
|
||||
log::error!("Websocket unauthorized, no credentials provided");
|
||||
return Err(Error::ClientError(
|
||||
break Err(Error::ClientError(
|
||||
"Unauthorized, no credentials provided".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
_ => Err(e),
|
||||
_ => break Err(e),
|
||||
},
|
||||
|
||||
_ => Err(e),
|
||||
_ => break Err(e),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<K: AuthenticationStore + Send + Sync> HTTPAPIClient<K> {
|
||||
pub fn new(base_url: Uri, auth_store: K) -> HTTPAPIClient<K> {
|
||||
|
||||
@@ -347,7 +347,16 @@ impl Daemon {
|
||||
self.database
|
||||
.lock()
|
||||
.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
|
||||
.unwrap();
|
||||
|
||||
@@ -448,18 +457,38 @@ impl Daemon {
|
||||
.get(&conversation_id)
|
||||
.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()
|
||||
.await
|
||||
.with_repository(|r| {
|
||||
r.get_messages_for_conversation(&conversation_id)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|m| m.into()) // Convert db::Message to daemon::Message
|
||||
.chain(outgoing_messages.into_iter().map(|m| m.into()))
|
||||
.collect()
|
||||
let msgs = r.get_messages_for_conversation(&conversation_id).unwrap();
|
||||
let ids: Vec<String> = msgs.iter().map(|m| m.id.clone()).collect();
|
||||
let map = r.get_local_ids_for(ids).unwrap_or_default();
|
||||
(msgs, map)
|
||||
})
|
||||
.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(
|
||||
|
||||
@@ -143,7 +143,10 @@ impl ClientCli {
|
||||
|
||||
println!("Listening for raw updates...");
|
||||
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 {
|
||||
SocketUpdate::Update(updates) => {
|
||||
for update in updates {
|
||||
@@ -154,6 +157,13 @@ impl ClientCli {
|
||||
println!("Pong");
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Err(e) => {
|
||||
println!("Update error: {:?}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -32,29 +32,29 @@
|
||||
/* End PBXCopyFilesBuildPhase 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 */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
CD41F5DA2E62850100E0027B /* Exceptions for "kordophone2" folder in "kordophone2" target */ = {
|
||||
CD41F5DA2E62850100E0027B /* Exceptions for "kordophone2" folder in "Kordophone" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Daemon/kordophoned,
|
||||
Daemon/net.buzzert.kordophonecd.plist,
|
||||
);
|
||||
target = CD41F5962E5B8E7300E0027B /* kordophone2 */;
|
||||
target = CD41F5962E5B8E7300E0027B /* Kordophone */;
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet 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;
|
||||
buildPhase = CD41F5D92E6284FD00E0027B /* CopyFiles */;
|
||||
membershipExceptions = (
|
||||
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;
|
||||
attributesByRelativePath = {
|
||||
Daemon/kordophoned = (CodeSignOnCopy, );
|
||||
@@ -70,9 +70,9 @@
|
||||
CD41F5992E5B8E7300E0027B /* kordophone2 */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
CD41F5DA2E62850100E0027B /* Exceptions for "kordophone2" folder in "kordophone2" target */,
|
||||
CD41F5DC2E62853800E0027B /* Exceptions for "kordophone2" folder in "Copy Files" phase from "kordophone2" target */,
|
||||
CD41F5E12E62860700E0027B /* Exceptions for "kordophone2" folder in "Copy Files" phase from "kordophone2" target */,
|
||||
CD41F5DA2E62850100E0027B /* Exceptions for "kordophone2" folder in "Kordophone" target */,
|
||||
CD41F5DC2E62853800E0027B /* Exceptions for "kordophone2" folder in "Copy Files" phase from "Kordophone" target */,
|
||||
CD41F5E12E62860700E0027B /* Exceptions for "kordophone2" folder in "Copy Files" phase from "Kordophone" target */,
|
||||
);
|
||||
path = kordophone2;
|
||||
sourceTree = "<group>";
|
||||
@@ -102,7 +102,7 @@
|
||||
CD41F5982E5B8E7300E0027B /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
CD41F5972E5B8E7300E0027B /* kordophone2.app */,
|
||||
CD41F5972E5B8E7300E0027B /* Kordophone.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@@ -110,9 +110,9 @@
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
CD41F5962E5B8E7300E0027B /* kordophone2 */ = {
|
||||
CD41F5962E5B8E7300E0027B /* Kordophone */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = CD41F5A32E5B8E7400E0027B /* Build configuration list for PBXNativeTarget "kordophone2" */;
|
||||
buildConfigurationList = CD41F5A32E5B8E7400E0027B /* Build configuration list for PBXNativeTarget "Kordophone" */;
|
||||
buildPhases = (
|
||||
CD41F5932E5B8E7300E0027B /* Sources */,
|
||||
CD41F5942E5B8E7300E0027B /* Frameworks */,
|
||||
@@ -127,12 +127,12 @@
|
||||
fileSystemSynchronizedGroups = (
|
||||
CD41F5992E5B8E7300E0027B /* kordophone2 */,
|
||||
);
|
||||
name = kordophone2;
|
||||
name = Kordophone;
|
||||
packageProductDependencies = (
|
||||
CD41F5D22E62431D00E0027B /* KeychainAccess */,
|
||||
);
|
||||
productName = kordophone2;
|
||||
productReference = CD41F5972E5B8E7300E0027B /* kordophone2.app */;
|
||||
productReference = CD41F5972E5B8E7300E0027B /* Kordophone.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
@@ -167,7 +167,7 @@
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
CD41F5962E5B8E7300E0027B /* kordophone2 */,
|
||||
CD41F5962E5B8E7300E0027B /* Kordophone */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
@@ -322,7 +322,7 @@
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = DQQH5H6GBD;
|
||||
DEVELOPMENT_TEAM = 3SJALV9BQ7;
|
||||
ENABLE_HARDENED_RUNTIME = NO;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -349,7 +349,7 @@
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = DQQH5H6GBD;
|
||||
DEVELOPMENT_TEAM = 3SJALV9BQ7;
|
||||
ENABLE_HARDENED_RUNTIME = NO;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -379,7 +379,7 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
CD41F5A32E5B8E7400E0027B /* Build configuration list for PBXNativeTarget "kordophone2" */ = {
|
||||
CD41F5A32E5B8E7400E0027B /* Build configuration list for PBXNativeTarget "Kordophone" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
CD41F5A42E5B8E7400E0027B /* Debug */,
|
||||
|
||||
@@ -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>
|
||||
@@ -14,6 +14,13 @@ struct KordophoneApp: App
|
||||
WindowGroup {
|
||||
SplitView()
|
||||
}
|
||||
.commands {
|
||||
TextEditingCommands()
|
||||
}
|
||||
|
||||
WindowGroup(id: .transcriptWindow, for: Display.Conversation.self) { selectedConversation in
|
||||
TranscriptWindowView(conversation: selectedConversation)
|
||||
}
|
||||
|
||||
Settings {
|
||||
PreferencesView()
|
||||
@@ -25,3 +32,42 @@ struct KordophoneApp: App
|
||||
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"
|
||||
}
|
||||
|
||||
@@ -11,9 +11,10 @@ struct ConversationListView: View
|
||||
{
|
||||
@Binding var model: ViewModel
|
||||
@Environment(\.xpcClient) private var xpcClient
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
|
||||
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
|
||||
|
||||
HStack(spacing: 0.0) {
|
||||
@@ -64,14 +65,14 @@ struct ConversationListView: View
|
||||
class ViewModel
|
||||
{
|
||||
var conversations: [Display.Conversation]
|
||||
var selectedConversations: Set<Display.Conversation.ID>
|
||||
var selectedConversation: Display.Conversation.ID?
|
||||
|
||||
private var needsReload: Bool = true
|
||||
private let client = XPCClient()
|
||||
|
||||
public init(conversations: [Display.Conversation] = []) {
|
||||
self.conversations = conversations
|
||||
self.selectedConversations = Set()
|
||||
self.selectedConversation = nil
|
||||
setNeedsReload()
|
||||
}
|
||||
|
||||
@@ -101,6 +102,11 @@ struct ConversationListView: View
|
||||
.map { Display.Conversation(from: $0) }
|
||||
|
||||
self.conversations = clientConversations
|
||||
|
||||
let unreadConversations = clientConversations.filter(\.isUnread)
|
||||
await MainActor.run {
|
||||
NSApplication.shared.dockTile.badgeLabel = unreadConversations.isEmpty ? nil : "\(unreadConversations.count)"
|
||||
}
|
||||
} catch {
|
||||
print("Error reloading conversations: \(error)")
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -36,6 +36,7 @@ struct MessageEntryView: View
|
||||
.font(.body)
|
||||
.scrollDisabled(true)
|
||||
.disabled(selectedConversation == nil)
|
||||
.id("messageEntry")
|
||||
}
|
||||
.padding(8.0)
|
||||
.background {
|
||||
|
||||
@@ -10,7 +10,7 @@ import XPC
|
||||
|
||||
enum Display
|
||||
{
|
||||
struct Conversation: Identifiable, Hashable
|
||||
struct Conversation: Identifiable, Hashable, Codable
|
||||
{
|
||||
let id: String
|
||||
let name: String?
|
||||
@@ -27,6 +27,10 @@ enum Display
|
||||
participants.count > 1
|
||||
}
|
||||
|
||||
var isUnread: Bool {
|
||||
unreadCount > 0
|
||||
}
|
||||
|
||||
init(from c: Serialized.Conversation) {
|
||||
self.id = c.guid
|
||||
self.name = c.displayName
|
||||
|
||||
@@ -15,7 +15,7 @@ struct SplitView: View
|
||||
|
||||
private let xpcClient = XPCClient()
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -28,10 +28,10 @@ struct SplitView: View
|
||||
ConversationView(transcriptModel: $transcriptViewModel, entryModel: $entryViewModel)
|
||||
.xpcClient(xpcClient)
|
||||
.selectedConversation(selectedConversation)
|
||||
.navigationTitle("Kordophone")
|
||||
.navigationSubtitle(selectedConversation?.displayName ?? "")
|
||||
.onChange(of: conversationListModel.selectedConversations) { oldValue, newValue in
|
||||
transcriptViewModel.displayedConversation = conversationListModel.conversations.first { $0.id == newValue.first }
|
||||
.navigationTitle(selectedConversation?.displayName ?? "Kordophone")
|
||||
.navigationSubtitle(selectedConversation?.participants.joined(separator: ", ") ?? "")
|
||||
.onChange(of: conversationListModel.selectedConversation) { oldValue, newValue in
|
||||
transcriptViewModel.displayedConversation = conversationListModel.conversations.first { $0.id == newValue }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ struct TextBubbleItemView: View
|
||||
|
||||
BubbleView(sender: sender, date: date) {
|
||||
HStack {
|
||||
Text(text)
|
||||
Text(text.linkifiedAttributedString())
|
||||
.foregroundStyle(textColor)
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
@@ -75,6 +75,7 @@ struct TextBubbleItemView: View
|
||||
.padding(.horizontal, 16.0)
|
||||
.padding(.vertical, 10.0)
|
||||
.background(bubbleColor)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -219,14 +220,16 @@ struct SenderAttributionView: View
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate extension CGFloat {
|
||||
fileprivate extension CGFloat
|
||||
{
|
||||
static let dominantCornerRadius = 16.0
|
||||
static let minorCornerRadius = 4.0
|
||||
static let minimumBubbleHorizontalPadding = 80.0
|
||||
static let imageMaxWidth = 380.0
|
||||
}
|
||||
|
||||
fileprivate extension CGSize {
|
||||
fileprivate extension CGSize
|
||||
{
|
||||
var aspectRatio: CGFloat { width / height }
|
||||
}
|
||||
|
||||
@@ -239,3 +242,28 @@ fileprivate func preferredBubbleWidth(forAttachmentSize attachmentSize: CGSize?,
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user