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::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]
|
||||
pub async fn create_community(
|
||||
state: State<'_, AppState>,
|
||||
|
|
@ -515,19 +553,230 @@ pub async fn reorder_channels(
|
|||
let channels = engine.reorder_channels(&community_id, &channel_ids)?;
|
||||
drop(engine);
|
||||
|
||||
// broadcast the reordering to peers via document sync
|
||||
// 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;
|
||||
}
|
||||
broadcast_sync(&state, &community_id).await;
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
// 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())
|
||||
}
|
||||
|
||||
// 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)
|
||||
pub fn clear(&mut self) {
|
||||
self.documents.clear();
|
||||
|
|
|
|||
|
|
@ -131,6 +131,12 @@ pub fn run() {
|
|||
commands::community::reorder_channels,
|
||||
commands::community::create_category,
|
||||
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::leave_voice_channel,
|
||||
commands::voice::update_voice_media_state,
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
}
|
||||
],
|
||||
"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": {
|
||||
|
|
|
|||
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 Button from "./components/common/Button";
|
||||
import SettingsModal from "./components/settings/SettingsModal";
|
||||
import CommunitySettingsModal from "./components/settings/CommunitySettingsModal";
|
||||
import SignUpScreen from "./components/auth/SignUpScreen";
|
||||
import SplashScreen from "./components/auth/SplashScreen";
|
||||
import UserDirectoryModal from "./components/directory/UserDirectoryModal";
|
||||
import ProfileCard from "./components/common/ProfileCard";
|
||||
import ProfileModal from "./components/common/ProfileModal";
|
||||
import { AlertTriangle } from "lucide-solid";
|
||||
|
||||
import {
|
||||
overlayMenuOpen,
|
||||
closeOverlay,
|
||||
activeModal,
|
||||
modalData,
|
||||
closeModal,
|
||||
openModal,
|
||||
initResponsive,
|
||||
|
|
@ -35,6 +38,8 @@ import {
|
|||
setActiveCommunity,
|
||||
activeCommunityId,
|
||||
addCommunity,
|
||||
activeCommunity,
|
||||
removeCommunity,
|
||||
} from "./stores/communities";
|
||||
import {
|
||||
setChannels,
|
||||
|
|
@ -51,6 +56,7 @@ import {
|
|||
removeMessage,
|
||||
} from "./stores/messages";
|
||||
import {
|
||||
members,
|
||||
setMembers,
|
||||
addTypingPeer,
|
||||
setPeerOnline,
|
||||
|
|
@ -122,6 +128,9 @@ const App: Component = () => {
|
|||
string | null
|
||||
>(null);
|
||||
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
|
||||
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 () => {
|
||||
cleanupResize = initResponsive();
|
||||
|
||||
|
|
@ -678,6 +696,80 @@ const App: Component = () => {
|
|||
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() {
|
||||
if (tauriAvailable()) {
|
||||
try {
|
||||
|
|
@ -1005,10 +1097,152 @@ const App: Component = () => {
|
|||
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
|
||||
isOpen={activeModal() === "directory"}
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
import type { Component } from "solid-js";
|
||||
import { Show, createSignal, createMemo } from "solid-js";
|
||||
import type { ChatMessage } from "../../lib/types";
|
||||
import { formatTime, formatTimeShort } from "../../lib/utils";
|
||||
import { renderMarkdown, isStandaloneImageUrl } from "../../lib/markdown";
|
||||
import { formatTime } from "../../lib/utils";
|
||||
import { renderMarkdown, getStandaloneMediaKind } from "../../lib/markdown";
|
||||
import type { MediaKind } from "../../lib/markdown";
|
||||
import { removeMessage } from "../../stores/messages";
|
||||
import { activeCommunityId } from "../../stores/communities";
|
||||
import { identity } from "../../stores/identity";
|
||||
import Avatar from "../common/Avatar";
|
||||
import Lightbox from "../common/Lightbox";
|
||||
import { openProfileCard } from "../../stores/ui";
|
||||
import * as tauri from "../../lib/tauri";
|
||||
|
||||
|
|
@ -14,6 +16,7 @@ interface MessageProps {
|
|||
message: ChatMessage;
|
||||
isGrouped: boolean;
|
||||
isFirstInGroup: boolean;
|
||||
isLastInGroup: boolean;
|
||||
}
|
||||
|
||||
const Message: Component<MessageProps> = (props) => {
|
||||
|
|
@ -29,7 +32,11 @@ const Message: Component<MessageProps> = (props) => {
|
|||
const renderedContent = createMemo(() =>
|
||||
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 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
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("click", closeContextMenu);
|
||||
|
|
@ -76,24 +92,18 @@ const Message: Component<MessageProps> = (props) => {
|
|||
|
||||
return (
|
||||
<div
|
||||
class={`flex gap-4 hover:bg-gray-900 transition-colors duration-200 ${
|
||||
props.isFirstInGroup ? "pt-4 px-4 pb-1" : "px-4 py-0.5"
|
||||
}`}
|
||||
class={`flex items-start gap-4 hover:bg-gray-900 transition-colors duration-200 px-4 ${
|
||||
props.isFirstInGroup ? "pt-2" : "pt-0.5"
|
||||
} ${props.isLastInGroup ? "pb-2" : "pb-0.5"}`}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
<Show
|
||||
when={props.isFirstInGroup}
|
||||
fallback={
|
||||
<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>
|
||||
}
|
||||
fallback={<div class="w-10 shrink-0" />}
|
||||
>
|
||||
<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}
|
||||
>
|
||||
<Avatar name={props.message.author_name} size="md" />
|
||||
|
|
@ -120,11 +130,12 @@ const Message: Component<MessageProps> = (props) => {
|
|||
</Show>
|
||||
|
||||
<Show
|
||||
when={!isImage()}
|
||||
when={!mediaKind()}
|
||||
fallback={
|
||||
<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()}
|
||||
onClick={handleMediaClick}
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
|
@ -132,6 +143,16 @@ const Message: Component<MessageProps> = (props) => {
|
|||
</Show>
|
||||
</div>
|
||||
|
||||
{/* media lightbox */}
|
||||
<Show when={mediaKind()}>
|
||||
<Lightbox
|
||||
isOpen={lightboxOpen()}
|
||||
onClose={() => setLightboxOpen(false)}
|
||||
src={props.message.content.trim()}
|
||||
type={mediaKind()!}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
{/* context menu */}
|
||||
<Show when={contextMenu()}>
|
||||
{(menu) => (
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
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 { isWithinGroupWindow, isDifferentDay, formatDaySeparator } from "../../lib/utils";
|
||||
import {
|
||||
isWithinGroupWindow,
|
||||
isDifferentDay,
|
||||
formatDaySeparator,
|
||||
} from "../../lib/utils";
|
||||
import Message from "./Message";
|
||||
import { ArrowDown } from "lucide-solid";
|
||||
|
||||
|
|
@ -14,6 +18,8 @@ const MessageList: Component<MessageListProps> = (props) => {
|
|||
let containerRef: HTMLDivElement | undefined;
|
||||
const [showScrollButton, setShowScrollButton] = createSignal(false);
|
||||
const [isAtBottom, setIsAtBottom] = createSignal(true);
|
||||
const [prevMessageCount, setPrevMessageCount] = createSignal(0);
|
||||
const [shouldAnimateLast, setShouldAnimateLast] = createSignal(false);
|
||||
|
||||
function scrollToBottom(smooth = true) {
|
||||
if (containerRef) {
|
||||
|
|
@ -33,6 +39,19 @@ const MessageList: Component<MessageListProps> = (props) => {
|
|||
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
|
||||
createEffect(() => {
|
||||
void props.messages.length;
|
||||
|
|
@ -54,11 +73,15 @@ const MessageList: Component<MessageListProps> = (props) => {
|
|||
class="h-full overflow-y-auto"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<div class="flex flex-col py-4">
|
||||
<div class="flex flex-col pb-4">
|
||||
<For each={props.messages}>
|
||||
{(message, index) => {
|
||||
const prev = () =>
|
||||
index() > 0 ? props.messages[index() - 1] : undefined;
|
||||
const next = () =>
|
||||
index() < props.messages.length - 1
|
||||
? props.messages[index() + 1]
|
||||
: undefined;
|
||||
const isFirstInGroup = () => {
|
||||
const p = prev();
|
||||
if (!p) return true;
|
||||
|
|
@ -67,6 +90,14 @@ const MessageList: Component<MessageListProps> = (props) => {
|
|||
return true;
|
||||
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 showDaySeparator = () => {
|
||||
const p = prev();
|
||||
|
|
@ -74,10 +105,12 @@ const MessageList: Component<MessageListProps> = (props) => {
|
|||
return isDifferentDay(p.timestamp, message.timestamp);
|
||||
};
|
||||
|
||||
const isLastMessage = () => index() === props.messages.length - 1;
|
||||
|
||||
return (
|
||||
<>
|
||||
<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" />
|
||||
<span class="text-[12px] font-mono text-white/40 uppercase tracking-[0.05em]">
|
||||
{formatDaySeparator(message.timestamp)}
|
||||
|
|
@ -85,11 +118,12 @@ const MessageList: Component<MessageListProps> = (props) => {
|
|||
<div class="flex-1 border-t border-white/10" />
|
||||
</div>
|
||||
</Show>
|
||||
<div class="animate-message-in">
|
||||
<div class={isLastMessage() && shouldAnimateLast() ? "animate-message-in" : ""}>
|
||||
<Message
|
||||
message={message}
|
||||
isGrouped={isGrouped()}
|
||||
isFirstInGroup={isFirstInGroup()}
|
||||
isLastInGroup={isLastInGroup()}
|
||||
/>
|
||||
</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 (
|
||||
<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()}>
|
||||
<div
|
||||
ref={triggerRef}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,9 @@ import {
|
|||
Headphones,
|
||||
HeadphoneOff,
|
||||
PhoneOff,
|
||||
Settings,
|
||||
UserPlus,
|
||||
LogOut,
|
||||
} from "lucide-solid";
|
||||
import {
|
||||
channels,
|
||||
|
|
@ -42,6 +45,8 @@ import {
|
|||
} from "../../stores/voice";
|
||||
import { identity } from "../../stores/identity";
|
||||
import SidebarLayout from "../common/SidebarLayout";
|
||||
import DropdownMenu from "../common/DropdownMenu";
|
||||
import type { DropdownItem } from "../common/DropdownMenu";
|
||||
import Avatar from "../common/Avatar";
|
||||
import type { ChannelMeta } from "../../lib/types";
|
||||
|
||||
|
|
@ -282,6 +287,33 @@ const ChannelList: Component = () => {
|
|||
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) => {
|
||||
if (channel.kind === "Voice") {
|
||||
// clicking a voice channel joins it (or switches to it)
|
||||
|
|
@ -350,7 +382,13 @@ const ChannelList: Component = () => {
|
|||
|
||||
const header = (
|
||||
<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
|
||||
when={community()}
|
||||
fallback={
|
||||
|
|
@ -361,13 +399,26 @@ const ChannelList: Component = () => {
|
|||
{community()!.name}
|
||||
</span>
|
||||
</Show>
|
||||
<button
|
||||
type="button"
|
||||
class="text-white/40 hover:text-white transition-colors duration-200 cursor-pointer"
|
||||
>
|
||||
<ChevronDown size={20} />
|
||||
</button>
|
||||
<Show when={community()}>
|
||||
<div class="text-white/40 hover:text-white transition-colors duration-200">
|
||||
<ChevronDown
|
||||
size={20}
|
||||
class="transition-transform duration-200"
|
||||
style={{
|
||||
transform: serverDropdownOpen()
|
||||
? "rotate(180deg)"
|
||||
: "rotate(0deg)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<DropdownMenu
|
||||
items={serverDropdownItems()}
|
||||
isOpen={serverDropdownOpen()}
|
||||
onClose={() => setServerDropdownOpen(false)}
|
||||
anchorRef={headerRef}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -282,18 +282,7 @@ const HomeView: Component = () => {
|
|||
</Show>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -50,18 +50,59 @@ function textNodeToMarkdown(node: JSONContent): string {
|
|||
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)
|
||||
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
|
||||
// only produces a limited set of elements - no script injection possible
|
||||
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)) {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -132,6 +132,54 @@ export async function reorderChannels(
|
|||
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 --
|
||||
|
||||
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 };
|
||||
|
|
|
|||
|
|
@ -46,4 +46,13 @@ export async function leaveCommunity(communityId: string): Promise<void> {
|
|||
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 };
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -570,3 +570,109 @@ body {
|
|||
object-fit: contain;
|
||||
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