feat: add CommunitySettingsModal component for managing community settings
This commit is contained in:
parent
26c1563ea9
commit
4bf42706c3
|
|
@ -9,6 +9,44 @@ use crate::protocol::community::{CategoryMeta, ChannelKind, ChannelMeta, Communi
|
||||||
use crate::protocol::messages::PeerStatus;
|
use crate::protocol::messages::PeerStatus;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
|
||||||
|
// check if the requester has one of the required roles in the community
|
||||||
|
fn check_permission(
|
||||||
|
members: &[Member],
|
||||||
|
requester_id: &str,
|
||||||
|
required_roles: &[&str],
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let requester = members
|
||||||
|
.iter()
|
||||||
|
.find(|m| m.peer_id == requester_id)
|
||||||
|
.ok_or("requester not found in community")?;
|
||||||
|
|
||||||
|
let has_permission = requester
|
||||||
|
.roles
|
||||||
|
.iter()
|
||||||
|
.any(|r| required_roles.contains(&r.as_str()));
|
||||||
|
|
||||||
|
if !has_permission {
|
||||||
|
return Err("insufficient permissions".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// helper to broadcast a crdt change to peers via the sync topic
|
||||||
|
async fn broadcast_sync(state: &State<'_, AppState>, community_id: &str) {
|
||||||
|
let node_handle = state.node_handle.lock().await;
|
||||||
|
if let Some(ref handle) = *node_handle {
|
||||||
|
let sync_topic = "dusk/sync".to_string();
|
||||||
|
let _ = handle
|
||||||
|
.command_tx
|
||||||
|
.send(NodeCommand::SendMessage {
|
||||||
|
topic: sync_topic,
|
||||||
|
data: community_id.as_bytes().to_vec(),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn create_community(
|
pub async fn create_community(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
|
|
@ -515,19 +553,230 @@ pub async fn reorder_channels(
|
||||||
let channels = engine.reorder_channels(&community_id, &channel_ids)?;
|
let channels = engine.reorder_channels(&community_id, &channel_ids)?;
|
||||||
drop(engine);
|
drop(engine);
|
||||||
|
|
||||||
// broadcast the reordering to peers via document sync
|
broadcast_sync(&state, &community_id).await;
|
||||||
// the change will propagate through the existing gossipsub sync mechanism
|
|
||||||
let node_handle = state.node_handle.lock().await;
|
|
||||||
if let Some(ref handle) = *node_handle {
|
|
||||||
let sync_topic = "dusk/sync".to_string();
|
|
||||||
let _ = handle
|
|
||||||
.command_tx
|
|
||||||
.send(NodeCommand::SendMessage {
|
|
||||||
topic: sync_topic,
|
|
||||||
data: community_id.as_bytes().to_vec(),
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(channels)
|
Ok(channels)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn update_community(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
community_id: String,
|
||||||
|
name: String,
|
||||||
|
description: String,
|
||||||
|
) -> Result<CommunityMeta, String> {
|
||||||
|
if name.len() > 64 {
|
||||||
|
return Err("community name must be 64 characters or fewer".to_string());
|
||||||
|
}
|
||||||
|
if description.len() > 256 {
|
||||||
|
return Err("description must be 256 characters or fewer".to_string());
|
||||||
|
}
|
||||||
|
if name.trim().is_empty() {
|
||||||
|
return Err("community name cannot be empty".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let identity = state.identity.lock().await;
|
||||||
|
let id = identity.as_ref().ok_or("no identity loaded")?;
|
||||||
|
let requester_id = id.peer_id.to_string();
|
||||||
|
drop(identity);
|
||||||
|
|
||||||
|
let engine = state.crdt_engine.lock().await;
|
||||||
|
let members = engine.get_members(&community_id)?;
|
||||||
|
check_permission(&members, &requester_id, &["owner", "admin"])?;
|
||||||
|
drop(engine);
|
||||||
|
|
||||||
|
let mut engine = state.crdt_engine.lock().await;
|
||||||
|
engine.update_community_meta(&community_id, &name, &description)?;
|
||||||
|
let meta = engine.get_community_meta(&community_id)?;
|
||||||
|
let _ = state.storage.save_community_meta(&meta);
|
||||||
|
drop(engine);
|
||||||
|
|
||||||
|
broadcast_sync(&state, &community_id).await;
|
||||||
|
|
||||||
|
Ok(meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn update_channel(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
community_id: String,
|
||||||
|
channel_id: String,
|
||||||
|
name: String,
|
||||||
|
topic: String,
|
||||||
|
) -> Result<ChannelMeta, String> {
|
||||||
|
if name.trim().is_empty() {
|
||||||
|
return Err("channel name cannot be empty".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let identity = state.identity.lock().await;
|
||||||
|
let id = identity.as_ref().ok_or("no identity loaded")?;
|
||||||
|
let requester_id = id.peer_id.to_string();
|
||||||
|
drop(identity);
|
||||||
|
|
||||||
|
let engine = state.crdt_engine.lock().await;
|
||||||
|
let members = engine.get_members(&community_id)?;
|
||||||
|
check_permission(&members, &requester_id, &["owner", "admin"])?;
|
||||||
|
drop(engine);
|
||||||
|
|
||||||
|
let mut engine = state.crdt_engine.lock().await;
|
||||||
|
engine.update_channel(&community_id, &channel_id, &name, &topic)?;
|
||||||
|
let channels = engine.get_channels(&community_id)?;
|
||||||
|
drop(engine);
|
||||||
|
|
||||||
|
let channel = channels
|
||||||
|
.into_iter()
|
||||||
|
.find(|c| c.id == channel_id)
|
||||||
|
.ok_or("channel not found after update")?;
|
||||||
|
|
||||||
|
broadcast_sync(&state, &community_id).await;
|
||||||
|
|
||||||
|
Ok(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn delete_channel(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
community_id: String,
|
||||||
|
channel_id: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let identity = state.identity.lock().await;
|
||||||
|
let id = identity.as_ref().ok_or("no identity loaded")?;
|
||||||
|
let requester_id = id.peer_id.to_string();
|
||||||
|
drop(identity);
|
||||||
|
|
||||||
|
let engine = state.crdt_engine.lock().await;
|
||||||
|
let members = engine.get_members(&community_id)?;
|
||||||
|
check_permission(&members, &requester_id, &["owner", "admin"])?;
|
||||||
|
drop(engine);
|
||||||
|
|
||||||
|
// unsubscribe from channel topics before deletion
|
||||||
|
let node_handle = state.node_handle.lock().await;
|
||||||
|
if let Some(ref handle) = *node_handle {
|
||||||
|
let msg_topic = gossip::topic_for_messages(&community_id, &channel_id);
|
||||||
|
let _ = handle
|
||||||
|
.command_tx
|
||||||
|
.send(NodeCommand::Unsubscribe { topic: msg_topic })
|
||||||
|
.await;
|
||||||
|
let typing_topic = gossip::topic_for_typing(&community_id, &channel_id);
|
||||||
|
let _ = handle
|
||||||
|
.command_tx
|
||||||
|
.send(NodeCommand::Unsubscribe {
|
||||||
|
topic: typing_topic,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
drop(node_handle);
|
||||||
|
|
||||||
|
let mut engine = state.crdt_engine.lock().await;
|
||||||
|
engine.delete_channel(&community_id, &channel_id)?;
|
||||||
|
drop(engine);
|
||||||
|
|
||||||
|
broadcast_sync(&state, &community_id).await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn delete_category(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
community_id: String,
|
||||||
|
category_id: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let identity = state.identity.lock().await;
|
||||||
|
let id = identity.as_ref().ok_or("no identity loaded")?;
|
||||||
|
let requester_id = id.peer_id.to_string();
|
||||||
|
drop(identity);
|
||||||
|
|
||||||
|
let engine = state.crdt_engine.lock().await;
|
||||||
|
let members = engine.get_members(&community_id)?;
|
||||||
|
check_permission(&members, &requester_id, &["owner", "admin"])?;
|
||||||
|
drop(engine);
|
||||||
|
|
||||||
|
let mut engine = state.crdt_engine.lock().await;
|
||||||
|
engine.delete_category(&community_id, &category_id)?;
|
||||||
|
drop(engine);
|
||||||
|
|
||||||
|
broadcast_sync(&state, &community_id).await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_member_role(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
community_id: String,
|
||||||
|
member_peer_id: String,
|
||||||
|
role: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if role != "admin" && role != "member" {
|
||||||
|
return Err("invalid role: must be 'admin' or 'member'".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let identity = state.identity.lock().await;
|
||||||
|
let id = identity.as_ref().ok_or("no identity loaded")?;
|
||||||
|
let requester_id = id.peer_id.to_string();
|
||||||
|
drop(identity);
|
||||||
|
|
||||||
|
// only the owner can change roles
|
||||||
|
let engine = state.crdt_engine.lock().await;
|
||||||
|
let members = engine.get_members(&community_id)?;
|
||||||
|
check_permission(&members, &requester_id, &["owner"])?;
|
||||||
|
|
||||||
|
// cannot change the owner's own role through this command
|
||||||
|
let target = members
|
||||||
|
.iter()
|
||||||
|
.find(|m| m.peer_id == member_peer_id)
|
||||||
|
.ok_or("member not found")?;
|
||||||
|
|
||||||
|
if target.roles.iter().any(|r| r == "owner") {
|
||||||
|
return Err("cannot change the owner's role, use transfer_ownership instead".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
drop(engine);
|
||||||
|
|
||||||
|
let mut engine = state.crdt_engine.lock().await;
|
||||||
|
engine.set_member_role(&community_id, &member_peer_id, &[role])?;
|
||||||
|
drop(engine);
|
||||||
|
|
||||||
|
broadcast_sync(&state, &community_id).await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn transfer_ownership(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
community_id: String,
|
||||||
|
new_owner_peer_id: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let identity = state.identity.lock().await;
|
||||||
|
let id = identity.as_ref().ok_or("no identity loaded")?;
|
||||||
|
let requester_id = id.peer_id.to_string();
|
||||||
|
drop(identity);
|
||||||
|
|
||||||
|
let engine = state.crdt_engine.lock().await;
|
||||||
|
let members = engine.get_members(&community_id)?;
|
||||||
|
check_permission(&members, &requester_id, &["owner"])?;
|
||||||
|
|
||||||
|
if requester_id == new_owner_peer_id {
|
||||||
|
return Err("cannot transfer ownership to yourself".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// verify the target is actually a member
|
||||||
|
members
|
||||||
|
.iter()
|
||||||
|
.find(|m| m.peer_id == new_owner_peer_id)
|
||||||
|
.ok_or("target member not found in community")?;
|
||||||
|
|
||||||
|
drop(engine);
|
||||||
|
|
||||||
|
let mut engine = state.crdt_engine.lock().await;
|
||||||
|
engine.transfer_ownership(&community_id, &requester_id, &new_owner_peer_id)?;
|
||||||
|
let meta = engine.get_community_meta(&community_id)?;
|
||||||
|
let _ = state.storage.save_community_meta(&meta);
|
||||||
|
drop(engine);
|
||||||
|
|
||||||
|
broadcast_sync(&state, &community_id).await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -564,3 +564,135 @@ pub fn remove_member(doc: &mut AutoCommit, peer_id: &str) -> Result<(), String>
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// update the community name and description in the meta map
|
||||||
|
pub fn update_community_meta(
|
||||||
|
doc: &mut AutoCommit,
|
||||||
|
name: &str,
|
||||||
|
description: &str,
|
||||||
|
) -> Result<(), automerge::AutomergeError> {
|
||||||
|
let meta = doc
|
||||||
|
.get(ROOT, "meta")?
|
||||||
|
.map(|(_, id)| id)
|
||||||
|
.ok_or_else(|| automerge::AutomergeError::InvalidObjId("meta not found".to_string()))?;
|
||||||
|
|
||||||
|
doc.put(&meta, "name", name)?;
|
||||||
|
doc.put(&meta, "description", description)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// update a channel's name and topic
|
||||||
|
pub fn update_channel(
|
||||||
|
doc: &mut AutoCommit,
|
||||||
|
channel_id: &str,
|
||||||
|
name: &str,
|
||||||
|
topic: &str,
|
||||||
|
) -> Result<(), automerge::AutomergeError> {
|
||||||
|
let channels = doc
|
||||||
|
.get(ROOT, "channels")?
|
||||||
|
.map(|(_, id)| id)
|
||||||
|
.ok_or_else(|| automerge::AutomergeError::InvalidObjId("channels not found".to_string()))?;
|
||||||
|
|
||||||
|
let channel = doc
|
||||||
|
.get(&channels, channel_id)?
|
||||||
|
.map(|(_, id)| id)
|
||||||
|
.ok_or_else(|| automerge::AutomergeError::InvalidObjId("channel not found".to_string()))?;
|
||||||
|
|
||||||
|
doc.put(&channel, "name", name)?;
|
||||||
|
doc.put(&channel, "topic", topic)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove a channel and all its messages from the document
|
||||||
|
pub fn delete_channel(
|
||||||
|
doc: &mut AutoCommit,
|
||||||
|
channel_id: &str,
|
||||||
|
) -> Result<(), automerge::AutomergeError> {
|
||||||
|
let channels = doc
|
||||||
|
.get(ROOT, "channels")?
|
||||||
|
.map(|(_, id)| id)
|
||||||
|
.ok_or_else(|| automerge::AutomergeError::InvalidObjId("channels not found".to_string()))?;
|
||||||
|
|
||||||
|
doc.delete(&channels, channel_id)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove a category and ungroup any channels that referenced it
|
||||||
|
pub fn delete_category(
|
||||||
|
doc: &mut AutoCommit,
|
||||||
|
category_id: &str,
|
||||||
|
) -> Result<(), automerge::AutomergeError> {
|
||||||
|
let categories = doc
|
||||||
|
.get(ROOT, "categories")?
|
||||||
|
.map(|(_, id)| id)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
automerge::AutomergeError::InvalidObjId("categories not found".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
doc.delete(&categories, category_id)?;
|
||||||
|
|
||||||
|
// clear category_id on any channels that were in this category
|
||||||
|
if let Some((_, channels_id)) = doc.get(ROOT, "channels")? {
|
||||||
|
let keys: Vec<String> = doc.keys(&channels_id).collect();
|
||||||
|
for key in keys {
|
||||||
|
if let Some((_, ch_id)) = doc.get(&channels_id, &key)? {
|
||||||
|
if let Some(cat_id) = get_str(doc, &ch_id, "category_id") {
|
||||||
|
if cat_id == category_id {
|
||||||
|
doc.delete(&ch_id, "category_id")?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// replace a member's roles list with the given roles
|
||||||
|
pub fn set_member_role(
|
||||||
|
doc: &mut AutoCommit,
|
||||||
|
peer_id: &str,
|
||||||
|
roles: &[String],
|
||||||
|
) -> Result<(), automerge::AutomergeError> {
|
||||||
|
let members = doc
|
||||||
|
.get(ROOT, "members")?
|
||||||
|
.map(|(_, id)| id)
|
||||||
|
.ok_or_else(|| automerge::AutomergeError::InvalidObjId("members not found".to_string()))?;
|
||||||
|
|
||||||
|
let member = doc
|
||||||
|
.get(&members, peer_id)?
|
||||||
|
.map(|(_, id)| id)
|
||||||
|
.ok_or_else(|| automerge::AutomergeError::InvalidObjId("member not found".to_string()))?;
|
||||||
|
|
||||||
|
// remove existing roles list and recreate with new roles
|
||||||
|
doc.delete(&member, "roles")?;
|
||||||
|
let roles_list = doc.put_object(&member, "roles", ObjType::List)?;
|
||||||
|
for (i, role) in roles.iter().enumerate() {
|
||||||
|
doc.insert(&roles_list, i, role.as_str())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// transfer ownership from one member to another
|
||||||
|
// demotes old owner to admin, promotes new member to owner, updates meta.created_by
|
||||||
|
pub fn transfer_ownership(
|
||||||
|
doc: &mut AutoCommit,
|
||||||
|
old_owner_id: &str,
|
||||||
|
new_owner_id: &str,
|
||||||
|
) -> Result<(), automerge::AutomergeError> {
|
||||||
|
set_member_role(doc, old_owner_id, &["admin".to_string()])?;
|
||||||
|
set_member_role(doc, new_owner_id, &["owner".to_string()])?;
|
||||||
|
|
||||||
|
let meta = doc
|
||||||
|
.get(ROOT, "meta")?
|
||||||
|
.map(|(_, id)| id)
|
||||||
|
.ok_or_else(|| automerge::AutomergeError::InvalidObjId("meta not found".to_string()))?;
|
||||||
|
|
||||||
|
doc.put(&meta, "created_by", new_owner_id)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -296,6 +296,115 @@ impl CrdtEngine {
|
||||||
self.documents.get_mut(community_id).map(|doc| doc.save())
|
self.documents.get_mut(community_id).map(|doc| doc.save())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// update community name and description
|
||||||
|
pub fn update_community_meta(
|
||||||
|
&mut self,
|
||||||
|
community_id: &str,
|
||||||
|
name: &str,
|
||||||
|
description: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let doc = self
|
||||||
|
.documents
|
||||||
|
.get_mut(community_id)
|
||||||
|
.ok_or("community not found")?;
|
||||||
|
|
||||||
|
document::update_community_meta(doc, name, description)
|
||||||
|
.map_err(|e| format!("failed to update community meta: {}", e))?;
|
||||||
|
|
||||||
|
self.persist(community_id)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// update a channel's name and topic
|
||||||
|
pub fn update_channel(
|
||||||
|
&mut self,
|
||||||
|
community_id: &str,
|
||||||
|
channel_id: &str,
|
||||||
|
name: &str,
|
||||||
|
topic: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let doc = self
|
||||||
|
.documents
|
||||||
|
.get_mut(community_id)
|
||||||
|
.ok_or("community not found")?;
|
||||||
|
|
||||||
|
document::update_channel(doc, channel_id, name, topic)
|
||||||
|
.map_err(|e| format!("failed to update channel: {}", e))?;
|
||||||
|
|
||||||
|
self.persist(community_id)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove a channel from a community
|
||||||
|
pub fn delete_channel(&mut self, community_id: &str, channel_id: &str) -> Result<(), String> {
|
||||||
|
let doc = self
|
||||||
|
.documents
|
||||||
|
.get_mut(community_id)
|
||||||
|
.ok_or("community not found")?;
|
||||||
|
|
||||||
|
document::delete_channel(doc, channel_id)
|
||||||
|
.map_err(|e| format!("failed to delete channel: {}", e))?;
|
||||||
|
|
||||||
|
self.persist(community_id)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove a category and ungroup its channels
|
||||||
|
pub fn delete_category(
|
||||||
|
&mut self,
|
||||||
|
community_id: &str,
|
||||||
|
category_id: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let doc = self
|
||||||
|
.documents
|
||||||
|
.get_mut(community_id)
|
||||||
|
.ok_or("community not found")?;
|
||||||
|
|
||||||
|
document::delete_category(doc, category_id)
|
||||||
|
.map_err(|e| format!("failed to delete category: {}", e))?;
|
||||||
|
|
||||||
|
self.persist(community_id)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// replace a member's roles
|
||||||
|
pub fn set_member_role(
|
||||||
|
&mut self,
|
||||||
|
community_id: &str,
|
||||||
|
peer_id: &str,
|
||||||
|
roles: &[String],
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let doc = self
|
||||||
|
.documents
|
||||||
|
.get_mut(community_id)
|
||||||
|
.ok_or("community not found")?;
|
||||||
|
|
||||||
|
document::set_member_role(doc, peer_id, roles)
|
||||||
|
.map_err(|e| format!("failed to set member role: {}", e))?;
|
||||||
|
|
||||||
|
self.persist(community_id)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// transfer ownership from one member to another
|
||||||
|
pub fn transfer_ownership(
|
||||||
|
&mut self,
|
||||||
|
community_id: &str,
|
||||||
|
old_owner_id: &str,
|
||||||
|
new_owner_id: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let doc = self
|
||||||
|
.documents
|
||||||
|
.get_mut(community_id)
|
||||||
|
.ok_or("community not found")?;
|
||||||
|
|
||||||
|
document::transfer_ownership(doc, old_owner_id, new_owner_id)
|
||||||
|
.map_err(|e| format!("failed to transfer ownership: {}", e))?;
|
||||||
|
|
||||||
|
self.persist(community_id)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
// drop all in-memory documents (used during identity reset)
|
// drop all in-memory documents (used during identity reset)
|
||||||
pub fn clear(&mut self) {
|
pub fn clear(&mut self) {
|
||||||
self.documents.clear();
|
self.documents.clear();
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,12 @@ pub fn run() {
|
||||||
commands::community::reorder_channels,
|
commands::community::reorder_channels,
|
||||||
commands::community::create_category,
|
commands::community::create_category,
|
||||||
commands::community::get_categories,
|
commands::community::get_categories,
|
||||||
|
commands::community::update_community,
|
||||||
|
commands::community::update_channel,
|
||||||
|
commands::community::delete_channel,
|
||||||
|
commands::community::delete_category,
|
||||||
|
commands::community::set_member_role,
|
||||||
|
commands::community::transfer_ownership,
|
||||||
commands::voice::join_voice_channel,
|
commands::voice::join_voice_channel,
|
||||||
commands::voice::leave_voice_channel,
|
commands::voice::leave_voice_channel,
|
||||||
commands::voice::update_voice_media_state,
|
commands::voice::update_voice_media_state,
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' asset: http://asset.localhost data: https://static.klipy.com; connect-src ipc: http://ipc.localhost; worker-src 'none'; object-src 'none'; base-uri 'self'"
|
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' asset: http://asset.localhost data: https://static.klipy.com https://*.tenor.com https://media.tenor.com https://media1.tenor.com https://c.tenor.com; connect-src ipc: http://ipc.localhost; worker-src 'none'; object-src 'none'; base-uri 'self'"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
|
|
|
||||||
234
src/App.tsx
234
src/App.tsx
|
|
@ -14,16 +14,19 @@ import MobileNav from "./components/navigation/MobileNav";
|
||||||
import Modal from "./components/common/Modal";
|
import Modal from "./components/common/Modal";
|
||||||
import Button from "./components/common/Button";
|
import Button from "./components/common/Button";
|
||||||
import SettingsModal from "./components/settings/SettingsModal";
|
import SettingsModal from "./components/settings/SettingsModal";
|
||||||
|
import CommunitySettingsModal from "./components/settings/CommunitySettingsModal";
|
||||||
import SignUpScreen from "./components/auth/SignUpScreen";
|
import SignUpScreen from "./components/auth/SignUpScreen";
|
||||||
import SplashScreen from "./components/auth/SplashScreen";
|
import SplashScreen from "./components/auth/SplashScreen";
|
||||||
import UserDirectoryModal from "./components/directory/UserDirectoryModal";
|
import UserDirectoryModal from "./components/directory/UserDirectoryModal";
|
||||||
import ProfileCard from "./components/common/ProfileCard";
|
import ProfileCard from "./components/common/ProfileCard";
|
||||||
import ProfileModal from "./components/common/ProfileModal";
|
import ProfileModal from "./components/common/ProfileModal";
|
||||||
|
import { AlertTriangle } from "lucide-solid";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
overlayMenuOpen,
|
overlayMenuOpen,
|
||||||
closeOverlay,
|
closeOverlay,
|
||||||
activeModal,
|
activeModal,
|
||||||
|
modalData,
|
||||||
closeModal,
|
closeModal,
|
||||||
openModal,
|
openModal,
|
||||||
initResponsive,
|
initResponsive,
|
||||||
|
|
@ -35,6 +38,8 @@ import {
|
||||||
setActiveCommunity,
|
setActiveCommunity,
|
||||||
activeCommunityId,
|
activeCommunityId,
|
||||||
addCommunity,
|
addCommunity,
|
||||||
|
activeCommunity,
|
||||||
|
removeCommunity,
|
||||||
} from "./stores/communities";
|
} from "./stores/communities";
|
||||||
import {
|
import {
|
||||||
setChannels,
|
setChannels,
|
||||||
|
|
@ -51,6 +56,7 @@ import {
|
||||||
removeMessage,
|
removeMessage,
|
||||||
} from "./stores/messages";
|
} from "./stores/messages";
|
||||||
import {
|
import {
|
||||||
|
members,
|
||||||
setMembers,
|
setMembers,
|
||||||
addTypingPeer,
|
addTypingPeer,
|
||||||
setPeerOnline,
|
setPeerOnline,
|
||||||
|
|
@ -122,6 +128,9 @@ const App: Component = () => {
|
||||||
string | null
|
string | null
|
||||||
>(null);
|
>(null);
|
||||||
const [newCategoryName, setNewCategoryName] = createSignal("");
|
const [newCategoryName, setNewCategoryName] = createSignal("");
|
||||||
|
const [inviteCode, setInviteCode] = createSignal("");
|
||||||
|
const [inviteLoading, setInviteLoading] = createSignal(false);
|
||||||
|
const [inviteCopied, setInviteCopied] = createSignal(false);
|
||||||
|
|
||||||
// react to community switches by loading channels, members, and selecting first channel
|
// react to community switches by loading channels, members, and selecting first channel
|
||||||
createEffect(
|
createEffect(
|
||||||
|
|
@ -205,6 +214,15 @@ const App: Component = () => {
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// automatically generate invite code when the invite modal opens
|
||||||
|
createEffect(
|
||||||
|
on(activeModal, (modal) => {
|
||||||
|
if (modal === "invite-people") {
|
||||||
|
handleOpenInvite();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
cleanupResize = initResponsive();
|
cleanupResize = initResponsive();
|
||||||
|
|
||||||
|
|
@ -678,6 +696,80 @@ const App: Component = () => {
|
||||||
closeModal();
|
closeModal();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// generates an invite code for the active community and opens the modal
|
||||||
|
async function handleOpenInvite() {
|
||||||
|
const communityId = activeCommunityId();
|
||||||
|
if (!communityId) return;
|
||||||
|
|
||||||
|
setInviteCode("");
|
||||||
|
setInviteCopied(false);
|
||||||
|
setInviteLoading(true);
|
||||||
|
|
||||||
|
if (tauriAvailable()) {
|
||||||
|
try {
|
||||||
|
const code = await tauri.generateInvite(communityId);
|
||||||
|
setInviteCode(code);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("failed to generate invite:", e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// demo mode - simulate invite code generation
|
||||||
|
setInviteCode("dusk_demo_invite_" + communityId.slice(4, 16));
|
||||||
|
}
|
||||||
|
|
||||||
|
setInviteLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCopyInvite() {
|
||||||
|
const code = inviteCode();
|
||||||
|
if (!code) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(code);
|
||||||
|
setInviteCopied(true);
|
||||||
|
setTimeout(() => setInviteCopied(false), 2000);
|
||||||
|
} catch {
|
||||||
|
// fallback for nonsecure contexts
|
||||||
|
const textarea = document.createElement("textarea");
|
||||||
|
textarea.value = code;
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.select();
|
||||||
|
document.execCommand("copy");
|
||||||
|
document.body.removeChild(textarea);
|
||||||
|
setInviteCopied(true);
|
||||||
|
setTimeout(() => setInviteCopied(false), 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if the current user is the owner of the active community
|
||||||
|
const isCurrentUserOwner = () => {
|
||||||
|
const id = identity();
|
||||||
|
const memberList = members();
|
||||||
|
if (!id) return false;
|
||||||
|
const self = memberList.find((m) => m.peer_id === id.peer_id);
|
||||||
|
return self?.roles.includes("owner") ?? false;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function handleLeaveServer() {
|
||||||
|
const communityId = activeCommunityId();
|
||||||
|
if (!communityId) return;
|
||||||
|
|
||||||
|
if (tauriAvailable()) {
|
||||||
|
try {
|
||||||
|
await tauri.leaveCommunity(communityId);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("failed to leave community:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removeCommunity(communityId);
|
||||||
|
setChannels([]);
|
||||||
|
setCategories([]);
|
||||||
|
setActiveChannel(null);
|
||||||
|
clearMessages();
|
||||||
|
setMembers([]);
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSaveSettings() {
|
async function handleSaveSettings() {
|
||||||
if (tauriAvailable()) {
|
if (tauriAvailable()) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -1005,10 +1097,152 @@ const App: Component = () => {
|
||||||
onResetIdentity={handleResetIdentity}
|
onResetIdentity={handleResetIdentity}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<CommunitySettingsModal
|
||||||
|
isOpen={activeModal() === "community-settings"}
|
||||||
|
onClose={closeModal}
|
||||||
|
communityId={
|
||||||
|
(modalData() as { communityId: string } | null)?.communityId ?? null
|
||||||
|
}
|
||||||
|
initialSection={
|
||||||
|
((modalData() as { initialSection?: string } | null)
|
||||||
|
?.initialSection as any) ?? undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<UserDirectoryModal
|
<UserDirectoryModal
|
||||||
isOpen={activeModal() === "directory"}
|
isOpen={activeModal() === "directory"}
|
||||||
onClose={closeModal}
|
onClose={closeModal}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* invite people modal */}
|
||||||
|
<Modal
|
||||||
|
isOpen={activeModal() === "invite-people"}
|
||||||
|
onClose={closeModal}
|
||||||
|
title="invite people"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<p class="text-[14px] text-white/60">
|
||||||
|
share this invite code with others so they can join{" "}
|
||||||
|
<span class="text-white font-bold">
|
||||||
|
{activeCommunity()?.name ?? "this server"}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<Show
|
||||||
|
when={!inviteLoading()}
|
||||||
|
fallback={
|
||||||
|
<div class="flex items-center justify-center py-6">
|
||||||
|
<span class="text-[14px] text-white/40 font-mono">
|
||||||
|
generating...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="flex-1 bg-black border-2 border-white/20 text-white text-[14px] font-mono px-4 py-3 outline-none select-all focus:border-orange transition-colors duration-200"
|
||||||
|
value={inviteCode()}
|
||||||
|
readOnly
|
||||||
|
onClick={(e) => e.currentTarget.select()}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant={inviteCopied() ? "secondary" : "primary"}
|
||||||
|
onClick={handleCopyInvite}
|
||||||
|
>
|
||||||
|
{inviteCopied() ? "copied" : "copy"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<p class="text-[12px] text-white/30 font-mono">
|
||||||
|
invite codes never contain IP addresses. peers discover each other
|
||||||
|
through the relay.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* leave server confirmation modal */}
|
||||||
|
<Modal
|
||||||
|
isOpen={activeModal() === "leave-server"}
|
||||||
|
onClose={closeModal}
|
||||||
|
title="leave server"
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when={isCurrentUserOwner()}
|
||||||
|
fallback={
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
<p class="text-[14px] text-white/60">
|
||||||
|
are you sure you want to leave{" "}
|
||||||
|
<span class="text-white font-bold">
|
||||||
|
{activeCommunity()?.name ?? "this server"}
|
||||||
|
</span>
|
||||||
|
? you can rejoin later with a new invite code.
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-3 justify-end">
|
||||||
|
<Button variant="secondary" onClick={closeModal}>
|
||||||
|
cancel
|
||||||
|
</Button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center justify-center h-12 px-6 text-[14px] font-medium uppercase tracking-[0.05em] bg-red-500 text-white border-none hover:bg-red-600 hover:scale-[0.98] active:scale-[0.96] transition-all duration-200 cursor-pointer select-none"
|
||||||
|
onClick={handleLeaveServer}
|
||||||
|
>
|
||||||
|
leave server
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
<div class="p-4 bg-orange/10 border-2 border-orange/30">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<AlertTriangle
|
||||||
|
size={20}
|
||||||
|
class="text-orange mt-0.5 shrink-0"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p class="text-[14px] text-white font-medium">
|
||||||
|
you are the owner of{" "}
|
||||||
|
<span class="text-orange font-bold">
|
||||||
|
{activeCommunity()?.name ?? "this server"}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p class="text-[13px] text-white/50 mt-1">
|
||||||
|
if you leave without transferring ownership, no one will
|
||||||
|
have owner permissions. consider transferring ownership to
|
||||||
|
another member first.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full h-12 text-[14px] font-medium uppercase tracking-[0.05em] bg-orange text-black hover:bg-orange/90 hover:scale-[0.98] active:scale-[0.96] transition-all duration-200 cursor-pointer select-none"
|
||||||
|
onClick={() => {
|
||||||
|
closeModal();
|
||||||
|
openModal("community-settings", {
|
||||||
|
communityId: activeCommunityId(),
|
||||||
|
initialSection: "members",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
transfer ownership
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full h-12 text-[14px] font-medium uppercase tracking-[0.05em] bg-red-500 text-white border-none hover:bg-red-600 hover:scale-[0.98] active:scale-[0.96] transition-all duration-200 cursor-pointer select-none"
|
||||||
|
onClick={handleLeaveServer}
|
||||||
|
>
|
||||||
|
leave anyway
|
||||||
|
</button>
|
||||||
|
<Button variant="secondary" onClick={closeModal}>
|
||||||
|
cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</Modal>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
import type { Component } from "solid-js";
|
import type { Component } from "solid-js";
|
||||||
import { Show, createSignal, createMemo } from "solid-js";
|
import { Show, createSignal, createMemo } from "solid-js";
|
||||||
import type { ChatMessage } from "../../lib/types";
|
import type { ChatMessage } from "../../lib/types";
|
||||||
import { formatTime, formatTimeShort } from "../../lib/utils";
|
import { formatTime } from "../../lib/utils";
|
||||||
import { renderMarkdown, isStandaloneImageUrl } from "../../lib/markdown";
|
import { renderMarkdown, getStandaloneMediaKind } from "../../lib/markdown";
|
||||||
|
import type { MediaKind } from "../../lib/markdown";
|
||||||
import { removeMessage } from "../../stores/messages";
|
import { removeMessage } from "../../stores/messages";
|
||||||
import { activeCommunityId } from "../../stores/communities";
|
import { activeCommunityId } from "../../stores/communities";
|
||||||
import { identity } from "../../stores/identity";
|
import { identity } from "../../stores/identity";
|
||||||
import Avatar from "../common/Avatar";
|
import Avatar from "../common/Avatar";
|
||||||
|
import Lightbox from "../common/Lightbox";
|
||||||
import { openProfileCard } from "../../stores/ui";
|
import { openProfileCard } from "../../stores/ui";
|
||||||
import * as tauri from "../../lib/tauri";
|
import * as tauri from "../../lib/tauri";
|
||||||
|
|
||||||
|
|
@ -14,6 +16,7 @@ interface MessageProps {
|
||||||
message: ChatMessage;
|
message: ChatMessage;
|
||||||
isGrouped: boolean;
|
isGrouped: boolean;
|
||||||
isFirstInGroup: boolean;
|
isFirstInGroup: boolean;
|
||||||
|
isLastInGroup: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Message: Component<MessageProps> = (props) => {
|
const Message: Component<MessageProps> = (props) => {
|
||||||
|
|
@ -29,7 +32,11 @@ const Message: Component<MessageProps> = (props) => {
|
||||||
const renderedContent = createMemo(() =>
|
const renderedContent = createMemo(() =>
|
||||||
renderMarkdown(props.message.content),
|
renderMarkdown(props.message.content),
|
||||||
);
|
);
|
||||||
const isImage = createMemo(() => isStandaloneImageUrl(props.message.content));
|
const mediaKind = createMemo<MediaKind>(() =>
|
||||||
|
getStandaloneMediaKind(props.message.content),
|
||||||
|
);
|
||||||
|
|
||||||
|
const [lightboxOpen, setLightboxOpen] = createSignal(false);
|
||||||
|
|
||||||
const isOwner = () => {
|
const isOwner = () => {
|
||||||
const user = currentUser();
|
const user = currentUser();
|
||||||
|
|
@ -69,6 +76,15 @@ const Message: Component<MessageProps> = (props) => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// opens lightbox when clicking any media element in the message
|
||||||
|
function handleMediaClick(e: MouseEvent) {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.classList.contains("dusk-media-clickable")) {
|
||||||
|
e.stopPropagation();
|
||||||
|
setLightboxOpen(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// close context menu on click outside
|
// close context menu on click outside
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
window.addEventListener("click", closeContextMenu);
|
window.addEventListener("click", closeContextMenu);
|
||||||
|
|
@ -76,24 +92,18 @@ const Message: Component<MessageProps> = (props) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class={`flex gap-4 hover:bg-gray-900 transition-colors duration-200 ${
|
class={`flex items-start gap-4 hover:bg-gray-900 transition-colors duration-200 px-4 ${
|
||||||
props.isFirstInGroup ? "pt-4 px-4 pb-1" : "px-4 py-0.5"
|
props.isFirstInGroup ? "pt-2" : "pt-0.5"
|
||||||
}`}
|
} ${props.isLastInGroup ? "pb-2" : "pb-0.5"}`}
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
when={props.isFirstInGroup}
|
when={props.isFirstInGroup}
|
||||||
fallback={
|
fallback={<div class="w-10 shrink-0" />}
|
||||||
<div class="w-10 shrink-0 flex items-start justify-center">
|
|
||||||
<span class="text-[11px] font-mono text-white/0 hover:text-white/40 transition-colors duration-200 leading-[22px]">
|
|
||||||
{formatTimeShort(props.message.timestamp)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="w-10 shrink-0 pt-0.5 cursor-pointer"
|
class="w-10 shrink-0 cursor-pointer mt-0.5"
|
||||||
onClick={handleProfileClick}
|
onClick={handleProfileClick}
|
||||||
>
|
>
|
||||||
<Avatar name={props.message.author_name} size="md" />
|
<Avatar name={props.message.author_name} size="md" />
|
||||||
|
|
@ -120,11 +130,12 @@ const Message: Component<MessageProps> = (props) => {
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show
|
<Show
|
||||||
when={!isImage()}
|
when={!mediaKind()}
|
||||||
fallback={
|
fallback={
|
||||||
<div
|
<div
|
||||||
class="dusk-msg-content dusk-msg-image-wrapper"
|
class={`dusk-msg-content ${mediaKind() === "image" ? "dusk-msg-image-wrapper" : "dusk-msg-video-wrapper"}`}
|
||||||
innerHTML={renderedContent()}
|
innerHTML={renderedContent()}
|
||||||
|
onClick={handleMediaClick}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
@ -132,6 +143,16 @@ const Message: Component<MessageProps> = (props) => {
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* media lightbox */}
|
||||||
|
<Show when={mediaKind()}>
|
||||||
|
<Lightbox
|
||||||
|
isOpen={lightboxOpen()}
|
||||||
|
onClose={() => setLightboxOpen(false)}
|
||||||
|
src={props.message.content.trim()}
|
||||||
|
type={mediaKind()!}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
|
||||||
{/* context menu */}
|
{/* context menu */}
|
||||||
<Show when={contextMenu()}>
|
<Show when={contextMenu()}>
|
||||||
{(menu) => (
|
{(menu) => (
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
import type { Component } from "solid-js";
|
import type { Component } from "solid-js";
|
||||||
import { For, Show, createEffect, createSignal, onMount } from "solid-js";
|
import { For, Show, createEffect, createSignal, onMount, untrack } from "solid-js";
|
||||||
import type { ChatMessage } from "../../lib/types";
|
import type { ChatMessage } from "../../lib/types";
|
||||||
import { isWithinGroupWindow, isDifferentDay, formatDaySeparator } from "../../lib/utils";
|
import {
|
||||||
|
isWithinGroupWindow,
|
||||||
|
isDifferentDay,
|
||||||
|
formatDaySeparator,
|
||||||
|
} from "../../lib/utils";
|
||||||
import Message from "./Message";
|
import Message from "./Message";
|
||||||
import { ArrowDown } from "lucide-solid";
|
import { ArrowDown } from "lucide-solid";
|
||||||
|
|
||||||
|
|
@ -14,6 +18,8 @@ const MessageList: Component<MessageListProps> = (props) => {
|
||||||
let containerRef: HTMLDivElement | undefined;
|
let containerRef: HTMLDivElement | undefined;
|
||||||
const [showScrollButton, setShowScrollButton] = createSignal(false);
|
const [showScrollButton, setShowScrollButton] = createSignal(false);
|
||||||
const [isAtBottom, setIsAtBottom] = createSignal(true);
|
const [isAtBottom, setIsAtBottom] = createSignal(true);
|
||||||
|
const [prevMessageCount, setPrevMessageCount] = createSignal(0);
|
||||||
|
const [shouldAnimateLast, setShouldAnimateLast] = createSignal(false);
|
||||||
|
|
||||||
function scrollToBottom(smooth = true) {
|
function scrollToBottom(smooth = true) {
|
||||||
if (containerRef) {
|
if (containerRef) {
|
||||||
|
|
@ -33,6 +39,19 @@ const MessageList: Component<MessageListProps> = (props) => {
|
||||||
setShowScrollButton(!atBottom);
|
setShowScrollButton(!atBottom);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// track when messages are actually added vs view changes
|
||||||
|
createEffect(() => {
|
||||||
|
const currentCount = props.messages.length;
|
||||||
|
const prevCount = untrack(() => prevMessageCount());
|
||||||
|
|
||||||
|
if (currentCount > prevCount && prevCount > 0) {
|
||||||
|
setShouldAnimateLast(true);
|
||||||
|
} else {
|
||||||
|
setShouldAnimateLast(false);
|
||||||
|
}
|
||||||
|
setPrevMessageCount(currentCount);
|
||||||
|
});
|
||||||
|
|
||||||
// auto-scroll when new messages arrive if user is at the bottom
|
// auto-scroll when new messages arrive if user is at the bottom
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
void props.messages.length;
|
void props.messages.length;
|
||||||
|
|
@ -54,11 +73,15 @@ const MessageList: Component<MessageListProps> = (props) => {
|
||||||
class="h-full overflow-y-auto"
|
class="h-full overflow-y-auto"
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
>
|
>
|
||||||
<div class="flex flex-col py-4">
|
<div class="flex flex-col pb-4">
|
||||||
<For each={props.messages}>
|
<For each={props.messages}>
|
||||||
{(message, index) => {
|
{(message, index) => {
|
||||||
const prev = () =>
|
const prev = () =>
|
||||||
index() > 0 ? props.messages[index() - 1] : undefined;
|
index() > 0 ? props.messages[index() - 1] : undefined;
|
||||||
|
const next = () =>
|
||||||
|
index() < props.messages.length - 1
|
||||||
|
? props.messages[index() + 1]
|
||||||
|
: undefined;
|
||||||
const isFirstInGroup = () => {
|
const isFirstInGroup = () => {
|
||||||
const p = prev();
|
const p = prev();
|
||||||
if (!p) return true;
|
if (!p) return true;
|
||||||
|
|
@ -67,6 +90,14 @@ const MessageList: Component<MessageListProps> = (props) => {
|
||||||
return true;
|
return true;
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
const isLastInGroup = () => {
|
||||||
|
const n = next();
|
||||||
|
if (!n) return true;
|
||||||
|
if (n.author_id !== message.author_id) return true;
|
||||||
|
if (!isWithinGroupWindow(message.timestamp, n.timestamp))
|
||||||
|
return true;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
const isGrouped = () => !isFirstInGroup();
|
const isGrouped = () => !isFirstInGroup();
|
||||||
const showDaySeparator = () => {
|
const showDaySeparator = () => {
|
||||||
const p = prev();
|
const p = prev();
|
||||||
|
|
@ -74,10 +105,12 @@ const MessageList: Component<MessageListProps> = (props) => {
|
||||||
return isDifferentDay(p.timestamp, message.timestamp);
|
return isDifferentDay(p.timestamp, message.timestamp);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isLastMessage = () => index() === props.messages.length - 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Show when={showDaySeparator()}>
|
<Show when={showDaySeparator()}>
|
||||||
<div class="flex items-center gap-4 px-4 py-2 my-2">
|
<div class="flex items-center gap-4 px-4 my-4">
|
||||||
<div class="flex-1 border-t border-white/10" />
|
<div class="flex-1 border-t border-white/10" />
|
||||||
<span class="text-[12px] font-mono text-white/40 uppercase tracking-[0.05em]">
|
<span class="text-[12px] font-mono text-white/40 uppercase tracking-[0.05em]">
|
||||||
{formatDaySeparator(message.timestamp)}
|
{formatDaySeparator(message.timestamp)}
|
||||||
|
|
@ -85,11 +118,12 @@ const MessageList: Component<MessageListProps> = (props) => {
|
||||||
<div class="flex-1 border-t border-white/10" />
|
<div class="flex-1 border-t border-white/10" />
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<div class="animate-message-in">
|
<div class={isLastMessage() && shouldAnimateLast() ? "animate-message-in" : ""}>
|
||||||
<Message
|
<Message
|
||||||
message={message}
|
message={message}
|
||||||
isGrouped={isGrouped()}
|
isGrouped={isGrouped()}
|
||||||
isFirstInGroup={isFirstInGroup()}
|
isFirstInGroup={isFirstInGroup()}
|
||||||
|
isLastInGroup={isLastInGroup()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
import type { Component } from "solid-js";
|
||||||
|
import { For, Show, onMount, onCleanup, createSignal } from "solid-js";
|
||||||
|
import { Portal, Dynamic } from "solid-js/web";
|
||||||
|
|
||||||
|
export interface DropdownItem {
|
||||||
|
label: string;
|
||||||
|
icon?: Component<{ size?: number; class?: string }>;
|
||||||
|
onClick: () => void;
|
||||||
|
// visually distinct destructive action (red text)
|
||||||
|
destructive?: boolean;
|
||||||
|
// divider rendered above this item
|
||||||
|
dividerAbove?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DropdownMenuProps {
|
||||||
|
items: DropdownItem[];
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
// anchor element ref for positioning
|
||||||
|
anchorRef?: HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DropdownMenu: Component<DropdownMenuProps> = (props) => {
|
||||||
|
let menuRef: HTMLDivElement | undefined;
|
||||||
|
const [position, setPosition] = createSignal({ top: 0, left: 0 });
|
||||||
|
|
||||||
|
// recompute position whenever the dropdown opens
|
||||||
|
const updatePosition = () => {
|
||||||
|
if (!props.anchorRef || !menuRef) return;
|
||||||
|
const rect = props.anchorRef.getBoundingClientRect();
|
||||||
|
// position directly below the anchor, aligned to the left edge
|
||||||
|
setPosition({
|
||||||
|
top: rect.bottom + 4,
|
||||||
|
left: rect.left,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// close on outside click
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (menuRef && !menuRef.contains(e.target as Node)) {
|
||||||
|
props.onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeydown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") props.onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
document.addEventListener("keydown", handleKeydown);
|
||||||
|
});
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
document.removeEventListener("keydown", handleKeydown);
|
||||||
|
});
|
||||||
|
|
||||||
|
// recalculate on open
|
||||||
|
const isOpen = () => {
|
||||||
|
if (props.isOpen) {
|
||||||
|
// defer to let the element render first
|
||||||
|
requestAnimationFrame(updatePosition);
|
||||||
|
}
|
||||||
|
return props.isOpen;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={isOpen()}>
|
||||||
|
<Portal>
|
||||||
|
<div
|
||||||
|
ref={menuRef}
|
||||||
|
class="fixed z-999 min-w-50 bg-gray-900 border border-white/10 shadow-xl animate-fade-in py-1"
|
||||||
|
style={{
|
||||||
|
top: `${position().top}px`,
|
||||||
|
left: `${position().left}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<For each={props.items}>
|
||||||
|
{(item) => (
|
||||||
|
<>
|
||||||
|
<Show when={item.dividerAbove}>
|
||||||
|
<div class="h-px bg-white/10 my-1" />
|
||||||
|
</Show>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={`flex items-center gap-2.5 w-full px-3 py-2 text-[14px] text-left transition-colors duration-150 cursor-pointer ${
|
||||||
|
item.destructive
|
||||||
|
? "text-red-400 hover:bg-red-500/10"
|
||||||
|
: "text-white/80 hover:bg-white/10 hover:text-white"
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
item.onClick();
|
||||||
|
props.onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Show when={item.icon}>
|
||||||
|
<Dynamic component={item.icon} size={16} class="shrink-0" />
|
||||||
|
</Show>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Portal>
|
||||||
|
</Show>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DropdownMenu;
|
||||||
|
|
@ -0,0 +1,173 @@
|
||||||
|
import type { Component } from "solid-js";
|
||||||
|
import { Show, createSignal, createEffect, onMount, onCleanup } from "solid-js";
|
||||||
|
import { Portal } from "solid-js/web";
|
||||||
|
import { X, ZoomIn, ZoomOut, RotateCcw } from "lucide-solid";
|
||||||
|
|
||||||
|
interface LightboxProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
src: string;
|
||||||
|
// "image" or "video" - determines how the media is rendered
|
||||||
|
type: "image" | "video";
|
||||||
|
alt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Lightbox: Component<LightboxProps> = (props) => {
|
||||||
|
const [scale, setScale] = createSignal(1);
|
||||||
|
const [translate, setTranslate] = createSignal({ x: 0, y: 0 });
|
||||||
|
const [dragging, setDragging] = createSignal(false);
|
||||||
|
const [dragStart, setDragStart] = createSignal({ x: 0, y: 0 });
|
||||||
|
|
||||||
|
function resetTransform() {
|
||||||
|
setScale(1);
|
||||||
|
setTranslate({ x: 0, y: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") props.onClose();
|
||||||
|
if (e.key === "+" || e.key === "=") zoomIn();
|
||||||
|
if (e.key === "-") zoomOut();
|
||||||
|
if (e.key === "0") resetTransform();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBackdropClick(e: MouseEvent) {
|
||||||
|
if (e.target === e.currentTarget) props.onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWheel(e: WheelEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const delta = e.deltaY > 0 ? -0.15 : 0.15;
|
||||||
|
setScale((prev) => Math.max(0.25, Math.min(5, prev + delta)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerDown(e: PointerEvent) {
|
||||||
|
// only pan images, not videos
|
||||||
|
if (props.type === "video") return;
|
||||||
|
e.preventDefault();
|
||||||
|
setDragging(true);
|
||||||
|
setDragStart({
|
||||||
|
x: e.clientX - translate().x,
|
||||||
|
y: e.clientY - translate().y,
|
||||||
|
});
|
||||||
|
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerMove(e: PointerEvent) {
|
||||||
|
if (!dragging()) return;
|
||||||
|
setTranslate({
|
||||||
|
x: e.clientX - dragStart().x,
|
||||||
|
y: e.clientY - dragStart().y,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerUp() {
|
||||||
|
setDragging(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoomIn() {
|
||||||
|
setScale((prev) => Math.min(5, prev + 0.25));
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoomOut() {
|
||||||
|
setScale((prev) => Math.max(0.25, prev - 0.25));
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
document.addEventListener("keydown", handleKeydown);
|
||||||
|
});
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
document.removeEventListener("keydown", handleKeydown);
|
||||||
|
});
|
||||||
|
|
||||||
|
// reset transform state when the lightbox opens
|
||||||
|
createEffect(() => {
|
||||||
|
if (props.isOpen) resetTransform();
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={props.isOpen}>
|
||||||
|
<Portal>
|
||||||
|
<div
|
||||||
|
class="dusk-lightbox-backdrop"
|
||||||
|
onClick={handleBackdropClick}
|
||||||
|
onWheel={handleWheel}
|
||||||
|
>
|
||||||
|
{/* toolbar */}
|
||||||
|
<div class="dusk-lightbox-toolbar">
|
||||||
|
<Show when={props.type === "image"}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="dusk-lightbox-btn"
|
||||||
|
onClick={zoomIn}
|
||||||
|
title="zoom in (+)"
|
||||||
|
>
|
||||||
|
<ZoomIn size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="dusk-lightbox-btn"
|
||||||
|
onClick={zoomOut}
|
||||||
|
title="zoom out (-)"
|
||||||
|
>
|
||||||
|
<ZoomOut size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="dusk-lightbox-btn"
|
||||||
|
onClick={resetTransform}
|
||||||
|
title="reset (0)"
|
||||||
|
>
|
||||||
|
<RotateCcw size={18} />
|
||||||
|
</button>
|
||||||
|
<div class="dusk-lightbox-divider" />
|
||||||
|
</Show>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="dusk-lightbox-btn"
|
||||||
|
onClick={props.onClose}
|
||||||
|
title="close (esc)"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* media container */}
|
||||||
|
<div class="dusk-lightbox-media-container">
|
||||||
|
<Show
|
||||||
|
when={props.type === "image"}
|
||||||
|
fallback={
|
||||||
|
<video
|
||||||
|
src={props.src}
|
||||||
|
class="dusk-lightbox-video"
|
||||||
|
controls
|
||||||
|
autoplay
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<track kind="captions" />
|
||||||
|
</video>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={props.src}
|
||||||
|
alt={props.alt || "media"}
|
||||||
|
class="dusk-lightbox-image"
|
||||||
|
style={{
|
||||||
|
transform: `translate(${translate().x}px, ${translate().y}px) scale(${scale()})`,
|
||||||
|
cursor: dragging() ? "grabbing" : "grab",
|
||||||
|
}}
|
||||||
|
draggable={false}
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={handlePointerUp}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Portal>
|
||||||
|
</Show>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Lightbox;
|
||||||
|
|
@ -65,7 +65,7 @@ const UserFooter: Component<UserFooterProps> = (props) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="h-16 shrink-0 flex items-center gap-3 px-3 bg-black border-t border-white/10 relative">
|
<div class="h-[67px] shrink-0 flex items-center gap-3 px-3 bg-black border-t border-white/10 relative">
|
||||||
<Show when={user()}>
|
<Show when={user()}>
|
||||||
<div
|
<div
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,9 @@ import {
|
||||||
Headphones,
|
Headphones,
|
||||||
HeadphoneOff,
|
HeadphoneOff,
|
||||||
PhoneOff,
|
PhoneOff,
|
||||||
|
Settings,
|
||||||
|
UserPlus,
|
||||||
|
LogOut,
|
||||||
} from "lucide-solid";
|
} from "lucide-solid";
|
||||||
import {
|
import {
|
||||||
channels,
|
channels,
|
||||||
|
|
@ -42,6 +45,8 @@ import {
|
||||||
} from "../../stores/voice";
|
} from "../../stores/voice";
|
||||||
import { identity } from "../../stores/identity";
|
import { identity } from "../../stores/identity";
|
||||||
import SidebarLayout from "../common/SidebarLayout";
|
import SidebarLayout from "../common/SidebarLayout";
|
||||||
|
import DropdownMenu from "../common/DropdownMenu";
|
||||||
|
import type { DropdownItem } from "../common/DropdownMenu";
|
||||||
import Avatar from "../common/Avatar";
|
import Avatar from "../common/Avatar";
|
||||||
import type { ChannelMeta } from "../../lib/types";
|
import type { ChannelMeta } from "../../lib/types";
|
||||||
|
|
||||||
|
|
@ -282,6 +287,33 @@ const ChannelList: Component = () => {
|
||||||
return all;
|
return all;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// server dropdown state
|
||||||
|
const [serverDropdownOpen, setServerDropdownOpen] = createSignal(false);
|
||||||
|
let headerRef: HTMLDivElement | undefined;
|
||||||
|
|
||||||
|
const serverDropdownItems = (): DropdownItem[] => [
|
||||||
|
{
|
||||||
|
label: "server settings",
|
||||||
|
icon: Settings,
|
||||||
|
onClick: () => {
|
||||||
|
const comm = community();
|
||||||
|
if (comm) openModal("community-settings", { communityId: comm.id });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "invite people",
|
||||||
|
icon: UserPlus,
|
||||||
|
onClick: () => openModal("invite-people"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "leave server",
|
||||||
|
icon: LogOut,
|
||||||
|
onClick: () => openModal("leave-server"),
|
||||||
|
destructive: true,
|
||||||
|
dividerAbove: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const handleChannelClick = (channel: ChannelMeta) => {
|
const handleChannelClick = (channel: ChannelMeta) => {
|
||||||
if (channel.kind === "Voice") {
|
if (channel.kind === "Voice") {
|
||||||
// clicking a voice channel joins it (or switches to it)
|
// clicking a voice channel joins it (or switches to it)
|
||||||
|
|
@ -350,7 +382,13 @@ const ChannelList: Component = () => {
|
||||||
|
|
||||||
const header = (
|
const header = (
|
||||||
<div class="h-15 border-b border-white/10 flex flex-col justify-end">
|
<div class="h-15 border-b border-white/10 flex flex-col justify-end">
|
||||||
<div class="h-12 flex items-center justify-between px-4">
|
<div
|
||||||
|
ref={headerRef}
|
||||||
|
class="h-12 flex items-center justify-between px-4 cursor-pointer hover:bg-white/5 transition-colors duration-200"
|
||||||
|
onClick={() => {
|
||||||
|
if (community()) setServerDropdownOpen((v) => !v);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Show
|
<Show
|
||||||
when={community()}
|
when={community()}
|
||||||
fallback={
|
fallback={
|
||||||
|
|
@ -361,13 +399,26 @@ const ChannelList: Component = () => {
|
||||||
{community()!.name}
|
{community()!.name}
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
<button
|
<Show when={community()}>
|
||||||
type="button"
|
<div class="text-white/40 hover:text-white transition-colors duration-200">
|
||||||
class="text-white/40 hover:text-white transition-colors duration-200 cursor-pointer"
|
<ChevronDown
|
||||||
>
|
size={20}
|
||||||
<ChevronDown size={20} />
|
class="transition-transform duration-200"
|
||||||
</button>
|
style={{
|
||||||
|
transform: serverDropdownOpen()
|
||||||
|
? "rotate(180deg)"
|
||||||
|
: "rotate(0deg)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<DropdownMenu
|
||||||
|
items={serverDropdownItems()}
|
||||||
|
isOpen={serverDropdownOpen()}
|
||||||
|
onClose={() => setServerDropdownOpen(false)}
|
||||||
|
anchorRef={headerRef}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -282,18 +282,7 @@ const HomeView: Component = () => {
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* status bar at the bottom */}
|
|
||||||
<div class="h-8 shrink-0 flex items-center justify-between px-6 border-t border-white/10">
|
|
||||||
<span class="text-[11px] font-mono text-white/30">
|
|
||||||
{nodeStatus() === "running"
|
|
||||||
? peerCount() > 0
|
|
||||||
? `connected - ${peerCount()} peers on network`
|
|
||||||
: "searching for peers..."
|
|
||||||
: nodeStatus() === "starting"
|
|
||||||
? "connecting to network..."
|
|
||||||
: "offline"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -50,18 +50,59 @@ function textNodeToMarkdown(node: JSONContent): string {
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// known gif/image cdn domains that may not use file extensions in their urls
|
||||||
|
const GIF_CDN_HOSTS = [
|
||||||
|
"static.klipy.com",
|
||||||
|
"media.tenor.com",
|
||||||
|
"media1.tenor.com",
|
||||||
|
"c.tenor.com",
|
||||||
|
];
|
||||||
|
|
||||||
// check if a string is a standalone image/gif url (no other text)
|
// check if a string is a standalone image/gif url (no other text)
|
||||||
export function isStandaloneImageUrl(text: string): boolean {
|
export function isStandaloneImageUrl(text: string): boolean {
|
||||||
return /^https?:\/\/\S+\.(gif|png|jpg|jpeg|webp)(\?\S*)?$/i.test(text.trim());
|
const trimmed = text.trim();
|
||||||
|
|
||||||
|
// match common image file extensions
|
||||||
|
if (/^https?:\/\/\S+\.(gif|png|jpg|jpeg|webp)(\?\S*)?$/i.test(trimmed)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// match known gif cdn domains regardless of extension
|
||||||
|
try {
|
||||||
|
const url = new URL(trimmed);
|
||||||
|
return GIF_CDN_HOSTS.includes(url.hostname);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if a string is a standalone video url
|
||||||
|
export function isStandaloneVideoUrl(text: string): boolean {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
return /^https?:\/\/\S+\.(mp4|webm|mov|ogg)(\?\S*)?$/i.test(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// determine what kind of standalone media a message contains, if any
|
||||||
|
export type MediaKind = "image" | "video" | null;
|
||||||
|
export function getStandaloneMediaKind(text: string): MediaKind {
|
||||||
|
if (isStandaloneImageUrl(text)) return "image";
|
||||||
|
if (isStandaloneVideoUrl(text)) return "video";
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// parse markdown-formatted text into safe html for display
|
// parse markdown-formatted text into safe html for display
|
||||||
// only produces a limited set of elements - no script injection possible
|
// only produces a limited set of elements - no script injection possible
|
||||||
export function renderMarkdown(text: string): string {
|
export function renderMarkdown(text: string): string {
|
||||||
// standalone image url gets rendered as a full image
|
// standalone image url gets rendered as a clickable image
|
||||||
if (isStandaloneImageUrl(text)) {
|
if (isStandaloneImageUrl(text)) {
|
||||||
const url = escapeHtml(text.trim());
|
const url = escapeHtml(text.trim());
|
||||||
return `<img src="${url}" class="dusk-msg-image" alt="image" loading="lazy" />`;
|
return `<img src="${url}" class="dusk-msg-image dusk-media-clickable" alt="image" loading="lazy" />`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// standalone video url gets rendered as an inline video player
|
||||||
|
if (isStandaloneVideoUrl(text)) {
|
||||||
|
const url = escapeHtml(text.trim());
|
||||||
|
return `<video src="${url}" class="dusk-msg-video dusk-media-clickable" preload="metadata" loop muted autoplay playsinline><track kind="captions" /></video>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// split by inline code spans to avoid parsing markdown inside code
|
// split by inline code spans to avoid parsing markdown inside code
|
||||||
|
|
|
||||||
|
|
@ -132,6 +132,54 @@ export async function reorderChannels(
|
||||||
return invoke("reorder_channels", { communityId, channelIds });
|
return invoke("reorder_channels", { communityId, channelIds });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- community settings --
|
||||||
|
|
||||||
|
export async function updateCommunity(
|
||||||
|
communityId: string,
|
||||||
|
name: string,
|
||||||
|
description: string,
|
||||||
|
): Promise<CommunityMeta> {
|
||||||
|
return invoke("update_community", { communityId, name, description });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateChannel(
|
||||||
|
communityId: string,
|
||||||
|
channelId: string,
|
||||||
|
name: string,
|
||||||
|
topic: string,
|
||||||
|
): Promise<ChannelMeta> {
|
||||||
|
return invoke("update_channel", { communityId, channelId, name, topic });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteChannel(
|
||||||
|
communityId: string,
|
||||||
|
channelId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
return invoke("delete_channel", { communityId, channelId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCategory(
|
||||||
|
communityId: string,
|
||||||
|
categoryId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
return invoke("delete_category", { communityId, categoryId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setMemberRole(
|
||||||
|
communityId: string,
|
||||||
|
memberPeerId: string,
|
||||||
|
role: string,
|
||||||
|
): Promise<void> {
|
||||||
|
return invoke("set_member_role", { communityId, memberPeerId, role });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function transferOwnership(
|
||||||
|
communityId: string,
|
||||||
|
newOwnerPeerId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
return invoke("transfer_ownership", { communityId, newOwnerPeerId });
|
||||||
|
}
|
||||||
|
|
||||||
// -- messages --
|
// -- messages --
|
||||||
|
|
||||||
export async function sendMessage(
|
export async function sendMessage(
|
||||||
|
|
|
||||||
|
|
@ -31,4 +31,32 @@ export async function reorderChannels(channelIds: string[]): Promise<void> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function removeChannel(channelId: string) {
|
||||||
|
setChannels((prev) => prev.filter((c) => c.id !== channelId));
|
||||||
|
// switch to the first remaining channel if the deleted one was active
|
||||||
|
if (activeChannelId() === channelId) {
|
||||||
|
const remaining = channels();
|
||||||
|
setActiveChannelId(remaining.length > 0 ? remaining[0].id : null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeCategory(categoryId: string) {
|
||||||
|
setCategories((prev) => prev.filter((c) => c.id !== categoryId));
|
||||||
|
// ungroup any channels that were in this category
|
||||||
|
setChannels((prev) =>
|
||||||
|
prev.map((c) =>
|
||||||
|
c.category_id === categoryId ? { ...c, category_id: null } : c,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateChannelMeta(
|
||||||
|
channelId: string,
|
||||||
|
updates: Partial<ChannelMeta>,
|
||||||
|
) {
|
||||||
|
setChannels((prev) =>
|
||||||
|
prev.map((c) => (c.id === channelId ? { ...c, ...updates } : c)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export { channels, categories, activeChannelId, setChannels, setCategories };
|
export { channels, categories, activeChannelId, setChannels, setCategories };
|
||||||
|
|
|
||||||
|
|
@ -46,4 +46,13 @@ export async function leaveCommunity(communityId: string): Promise<void> {
|
||||||
removeCommunity(communityId);
|
removeCommunity(communityId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function updateCommunityMeta(
|
||||||
|
id: string,
|
||||||
|
updates: Partial<CommunityMeta>,
|
||||||
|
) {
|
||||||
|
setCommunities((prev) =>
|
||||||
|
prev.map((c) => (c.id === id ? { ...c, ...updates } : c)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export { communities, activeCommunityId, setCommunities };
|
export { communities, activeCommunityId, setCommunities };
|
||||||
|
|
|
||||||
|
|
@ -105,4 +105,10 @@ export function removeMember(peerId: string) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function updateMemberRole(peerId: string, roles: string[]) {
|
||||||
|
setMembers((prev) =>
|
||||||
|
prev.map((m) => (m.peer_id === peerId ? { ...m, roles } : m)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export { members, typingPeerIds, setMembers, onlinePeerIds };
|
export { members, typingPeerIds, setMembers, onlinePeerIds };
|
||||||
|
|
|
||||||
|
|
@ -570,3 +570,109 @@ body {
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* clickable media in messages */
|
||||||
|
.dusk-media-clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dusk-media-clickable:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* inline video in messages */
|
||||||
|
.dusk-msg-video-wrapper {
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dusk-msg-video {
|
||||||
|
max-width: 400px;
|
||||||
|
max-height: 300px;
|
||||||
|
object-fit: contain;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
lightbox overlay
|
||||||
|
============================================================ */
|
||||||
|
.dusk-lightbox-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 3000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.92);
|
||||||
|
animation: dusk-lightbox-fade-in 150ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dusk-lightbox-fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dusk-lightbox-toolbar {
|
||||||
|
position: fixed;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
z-index: 3001;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dusk-lightbox-btn {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
color 200ms,
|
||||||
|
background 200ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dusk-lightbox-btn:hover {
|
||||||
|
color: white;
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dusk-lightbox-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 20px;
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
margin: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dusk-lightbox-media-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 90vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dusk-lightbox-image {
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 90vh;
|
||||||
|
object-fit: contain;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
transition: transform 100ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dusk-lightbox-video {
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 90vh;
|
||||||
|
object-fit: contain;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue