mirror of
https://github.com/logos-messaging/lab.waku.org.git
synced 2026-01-03 14:23:14 +00:00
add legend explaining sds concepts
This commit is contained in:
parent
45909aee7d
commit
054760e752
@ -5,6 +5,8 @@
|
||||
import { getIdenticon } from '$lib/identicon.svelte';
|
||||
import { getMessageId } from '$lib/sds/message';
|
||||
import type { MessageChannelEventObject } from '$lib/sds/stream';
|
||||
import HistoryItem from './HistoryItem.svelte';
|
||||
import LegendModal from './LegendModal.svelte';
|
||||
|
||||
// Map event types to colors using index signature
|
||||
const eventColors: { [key in string]: string } = {
|
||||
@ -31,6 +33,7 @@
|
||||
let identicon: any = $state(null);
|
||||
let currentFilter: string = $state('all');
|
||||
let currentIdFilter: string | null = $state(null);
|
||||
let showLegend: boolean = $state(false);
|
||||
|
||||
// Map of filter values to event types
|
||||
const filterMap: { [key: string]: string | null } = {
|
||||
@ -127,6 +130,11 @@
|
||||
currentIdFilter = null;
|
||||
}
|
||||
|
||||
// Toggle legend display
|
||||
function toggleLegend() {
|
||||
showLegend = !showLegend;
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
identicon = await getIdenticon();
|
||||
// Subscribe to the event stream and collect events
|
||||
@ -137,6 +145,7 @@
|
||||
}
|
||||
history = [event, ...history];
|
||||
});
|
||||
(window as any).saveHistory = saveHistory;
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
@ -145,16 +154,32 @@
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
const saveHistory = () => {
|
||||
const sampleHistory = history.map((event) => {
|
||||
if((event.payload as any).bloomFilter) {
|
||||
(event.payload as any).bloomFilter = new Uint8Array([0, 0, 0, 0]);
|
||||
}
|
||||
return {
|
||||
type: event.type,
|
||||
payload: event.payload
|
||||
};
|
||||
});
|
||||
localStorage.setItem('history', JSON.stringify(sampleHistory));
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="history-container">
|
||||
<select class="item-filter" onchange={handleFilterChange} value={currentFilter}>
|
||||
<option value="all">All ({eventCounts.all})</option>
|
||||
<option value="sent">Sent ({eventCounts.sent})</option>
|
||||
<option value="received">Received ({eventCounts.received})</option>
|
||||
<option value="delivered">Delivered ({eventCounts.delivered})</option>
|
||||
<option value="acknowledged">Acknowledged ({eventCounts.acknowledged})</option>
|
||||
</select>
|
||||
<div class="header">
|
||||
<button class="help-button" onclick={toggleLegend}>?</button>
|
||||
<select class="item-filter" onchange={handleFilterChange} value={currentFilter}>
|
||||
<option value="all">All ({eventCounts.all})</option>
|
||||
<option value="sent">Sent ({eventCounts.sent})</option>
|
||||
<option value="received">Received ({eventCounts.received})</option>
|
||||
<option value="delivered">Delivered ({eventCounts.delivered})</option>
|
||||
<option value="acknowledged">Acknowledged ({eventCounts.acknowledged})</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if currentIdFilter}
|
||||
<div class="id-filter-badge">
|
||||
@ -164,54 +189,16 @@
|
||||
{/if}
|
||||
|
||||
{#each filteredHistory as event, index}
|
||||
{@const color = eventColors[event.type] || '#888'}
|
||||
{@const name = eventNames[event.type] || event.type}
|
||||
{@const id = getMessageId(event)}
|
||||
{@const matchesFilter = currentIdFilter && id === currentIdFilter}
|
||||
<div class="history-item" onclick={() => handleEventClick(id)}>
|
||||
<div class="item-container">
|
||||
<div
|
||||
class="event-box {matchesFilter ? 'highlight' : ''}"
|
||||
style="background-color: {color};"
|
||||
>
|
||||
<div class="identicon">
|
||||
<img src="data:image/svg+xml;base64,{identicons[index]}" alt="Identicon" />
|
||||
</div>
|
||||
<div class="event-info">
|
||||
<div class="event-type">
|
||||
{name}
|
||||
</div>
|
||||
<div class="event-id">
|
||||
{id}
|
||||
</div>
|
||||
</div>
|
||||
{#if event.type === MessageChannelEvent.MessageDelivered}
|
||||
<div class="lamport-timestamp">
|
||||
{event.payload.sentOrReceived}
|
||||
</div>
|
||||
{/if}
|
||||
{#if event.type === MessageChannelEvent.MessageSent || event.type === MessageChannelEvent.MessageReceived}
|
||||
<div class="lamport-timestamp">
|
||||
{event.payload.lamportTimestamp}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if event.type === MessageChannelEvent.MessageSent || event.type === MessageChannelEvent.MessageReceived}
|
||||
{#each event.payload.causalHistory as dependency}
|
||||
{@const dependencyMatchesFilter =
|
||||
currentIdFilter && dependency.messageId === currentIdFilter}
|
||||
<div
|
||||
class="dependency-box {dependencyMatchesFilter ? 'highlight' : ''}"
|
||||
style="background-color: {color};"
|
||||
onclick={(event) => handleDependencyClick(dependency.messageId, event)}
|
||||
>
|
||||
{dependency.messageId}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<HistoryItem
|
||||
{event}
|
||||
identicon={identicons[index]}
|
||||
currentIdFilter={currentIdFilter}
|
||||
onEventClick={handleEventClick}
|
||||
onDependencyClick={handleDependencyClick}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
<LegendModal bind:isOpen={showLegend} />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@ -228,135 +215,39 @@
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.history-item {
|
||||
padding: 8px;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.item-container {
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.event-box {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
border-radius: 8px;
|
||||
width: 100%;
|
||||
min-height: 70px;
|
||||
color: white;
|
||||
padding: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
position: relative;
|
||||
transition: box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.dependency-box {
|
||||
.help-button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background-color: #f3f4f6;
|
||||
border: 1px solid #d1d5db;
|
||||
color: #4b5563;
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: auto;
|
||||
width: auto;
|
||||
max-width: 80%;
|
||||
min-height: 40px;
|
||||
font-size: 11px;
|
||||
font-family: monospace;
|
||||
opacity: 0.85;
|
||||
padding: 6px 12px;
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
transition: box-shadow 0.3s ease;
|
||||
cursor: pointer;
|
||||
margin-right: 8px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
border-left: 4px solid white;
|
||||
border-right: 4px solid white;
|
||||
position: relative;
|
||||
background-image: linear-gradient(rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.1));
|
||||
.help-button:hover {
|
||||
background-color: #e5e7eb;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.highlight .event-type {
|
||||
font-size: 16px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
font-style: italic;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.highlight .event-id,
|
||||
.dependency-box.highlight {
|
||||
/* color: white; */
|
||||
font-weight: bold;
|
||||
font-style: italic;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.dependency-box.highlight {
|
||||
font-size: 12px;
|
||||
/* background-color: rgba(255, 255, 255, 0.2) !important; */
|
||||
}
|
||||
|
||||
.identicon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
.item-filter {
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.identicon img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.identicon-placeholder {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 4px;
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.event-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.event-type {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.event-id {
|
||||
font-family: monospace;
|
||||
font-size: 10px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.lamport-timestamp {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 12px;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-weight: 500;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.id-filter-badge {
|
||||
@ -365,7 +256,7 @@
|
||||
background-color: #f3f4f6;
|
||||
border-radius: 16px;
|
||||
padding: 4px 12px;
|
||||
margin: 8px 0;
|
||||
margin: 8px;
|
||||
max-width: fit-content;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
222
examples/sds-demo/src/lib/components/HistoryItem.svelte
Normal file
222
examples/sds-demo/src/lib/components/HistoryItem.svelte
Normal file
@ -0,0 +1,222 @@
|
||||
<script lang="ts">
|
||||
import { MessageChannelEvent } from '@waku/sds';
|
||||
import type { MessageChannelEventObject } from '$lib/sds/stream';
|
||||
import { getMessageId } from '$lib/sds/message';
|
||||
|
||||
export let event: MessageChannelEventObject;
|
||||
export let identicon: string;
|
||||
export let currentIdFilter: string | null = null;
|
||||
export let onEventClick: (id: string | null) => void;
|
||||
export let onDependencyClick: (messageId: string, event: Event) => void;
|
||||
|
||||
// Map event types to colors using index signature
|
||||
const eventColors: { [key in string]: string } = {
|
||||
[MessageChannelEvent.MessageSent]: '#3B82F6', // blue
|
||||
[MessageChannelEvent.MessageDelivered]: '#10B981', // green
|
||||
[MessageChannelEvent.MessageReceived]: '#8B5CF6', // purple
|
||||
[MessageChannelEvent.MessageAcknowledged]: '#059669', // dark green
|
||||
[MessageChannelEvent.PartialAcknowledgement]: '#6D28D9', // dark purple
|
||||
[MessageChannelEvent.MissedMessages]: '#EF4444' // red
|
||||
};
|
||||
|
||||
// Event type to display name using index signature
|
||||
const eventNames: { [key in string]: string } = {
|
||||
[MessageChannelEvent.MessageSent]: 'Sent',
|
||||
[MessageChannelEvent.MessageDelivered]: 'Delivered',
|
||||
[MessageChannelEvent.MessageReceived]: 'Received',
|
||||
[MessageChannelEvent.MessageAcknowledged]: 'Acknowledged',
|
||||
[MessageChannelEvent.PartialAcknowledgement]: 'Partially Acknowledged',
|
||||
[MessageChannelEvent.MissedMessages]: 'Missed'
|
||||
};
|
||||
|
||||
$: id = getMessageId(event);
|
||||
$: color = eventColors[event.type] || '#888';
|
||||
$: name = eventNames[event.type] || event.type;
|
||||
$: matchesFilter = currentIdFilter && id === currentIdFilter;
|
||||
|
||||
function handleEventClick() {
|
||||
onEventClick(id);
|
||||
}
|
||||
|
||||
function handleDependencyClick(messageId: string, e: Event) {
|
||||
onDependencyClick(messageId, e);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="history-item" on:click={handleEventClick}>
|
||||
<div class="item-container">
|
||||
<div
|
||||
class="event-box {matchesFilter ? 'highlight' : ''}"
|
||||
style="background-color: {color};"
|
||||
>
|
||||
<div class="identicon">
|
||||
<img src="data:image/svg+xml;base64,{identicon}" alt="Identicon" />
|
||||
</div>
|
||||
<div class="event-info">
|
||||
<div class="event-type">
|
||||
{name}
|
||||
</div>
|
||||
<div class="event-id">
|
||||
{id}
|
||||
</div>
|
||||
</div>
|
||||
{#if event.type === MessageChannelEvent.MessageDelivered}
|
||||
<div class="sent-or-received">
|
||||
{event.payload.sentOrReceived}
|
||||
</div>
|
||||
{/if}
|
||||
{#if event.type === MessageChannelEvent.MessageSent || event.type === MessageChannelEvent.MessageReceived}
|
||||
<div class="lamport-timestamp">
|
||||
{event.payload.lamportTimestamp}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if event.type === MessageChannelEvent.MessageSent || event.type === MessageChannelEvent.MessageReceived}
|
||||
<!-- <section class="dependency-container"> -->
|
||||
{#each event.payload.causalHistory as dependency}
|
||||
{@const dependencyMatchesFilter =
|
||||
currentIdFilter && dependency.messageId === currentIdFilter}
|
||||
<div
|
||||
class="dependency-box {dependencyMatchesFilter ? 'highlight' : ''}"
|
||||
style="background-color: {color};"
|
||||
on:click={(e) => handleDependencyClick(dependency.messageId, e)}
|
||||
>
|
||||
{dependency.messageId}
|
||||
</div>
|
||||
{/each}
|
||||
<!-- </section> -->
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.history-item {
|
||||
padding: 8px;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.item-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.event-box {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
border-radius: 8px;
|
||||
width: 100%;
|
||||
min-height: 70px;
|
||||
color: white;
|
||||
padding: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
position: relative;
|
||||
transition: box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.dependency-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: auto;
|
||||
width: auto;
|
||||
max-width: 80%;
|
||||
min-height: 40px;
|
||||
font-size: 11px;
|
||||
font-family: monospace;
|
||||
opacity: 0.85;
|
||||
padding: 6px 12px;
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
transition: box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
border-left: 4px solid white;
|
||||
border-right: 4px solid white;
|
||||
position: relative;
|
||||
background-image: linear-gradient(rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.1));
|
||||
}
|
||||
|
||||
.highlight .event-type {
|
||||
font-size: 16px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
font-style: italic;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.highlight .event-id,
|
||||
.dependency-box.highlight {
|
||||
font-weight: bold;
|
||||
font-style: italic;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.dependency-box.highlight {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.identicon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.identicon img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.event-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.event-type {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.event-id {
|
||||
font-family: monospace;
|
||||
font-size: 10px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
max-width: 220px;
|
||||
/* overflow: hidden; */
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.lamport-timestamp {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 12px;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sent-or-received {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 12px;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
64
examples/sds-demo/src/lib/components/LegendDemo.svelte
Normal file
64
examples/sds-demo/src/lib/components/LegendDemo.svelte
Normal file
@ -0,0 +1,64 @@
|
||||
<script lang="ts">
|
||||
import LegendModal from './LegendModal.svelte';
|
||||
|
||||
let showLegend = $state(false);
|
||||
|
||||
function toggleLegend() {
|
||||
showLegend = !showLegend;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="legend-demo">
|
||||
<h2>Message Events Visualization</h2>
|
||||
|
||||
<div class="actions">
|
||||
<button class="info-button" on:click={toggleLegend}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="12" y1="16" x2="12" y2="12"></line>
|
||||
<line x1="12" y1="8" x2="12.01" y2="8"></line>
|
||||
</svg>
|
||||
Show Event Types Legend
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<LegendModal bind:isOpen={showLegend} />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.legend-demo {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.info-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background-color: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: #374151;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.info-button:hover {
|
||||
background-color: #f3f4f6;
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
|
||||
.info-button svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
</style>
|
||||
371
examples/sds-demo/src/lib/components/LegendModal.svelte
Normal file
371
examples/sds-demo/src/lib/components/LegendModal.svelte
Normal file
@ -0,0 +1,371 @@
|
||||
<script lang="ts">
|
||||
import { historyJson } from "$lib/data/history_sample";
|
||||
import type { MessageChannelEventObject } from "$lib/sds/stream";
|
||||
import { MessageChannelEvent } from "@waku/sds";
|
||||
import HistoryItem from "$lib/components/HistoryItem.svelte";
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { createTooltip } from '$lib/utils/tooltipUtils';
|
||||
|
||||
// Parse history data
|
||||
const history: MessageChannelEventObject[] = JSON.parse(historyJson);
|
||||
|
||||
// Create sample identicons (using a placeholder)
|
||||
const placeholderIdenticon = "PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MCIgaGVpZ2h0PSI0MCIgdmlld0JveD0iMCAwIDQwIDQwIj48cmVjdCB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIGZpbGw9IiNkZGQiLz48L3N2Zz4=";
|
||||
|
||||
// Sample handler functions - they don't need to do anything in the legend
|
||||
function handleItemClick(id: string | null) {
|
||||
// No action needed for the legend
|
||||
}
|
||||
|
||||
function handleDependencyClick(messageId: string, event: Event) {
|
||||
// Prevent event bubbling
|
||||
event.stopPropagation();
|
||||
// No action needed for the legend
|
||||
}
|
||||
|
||||
// Get one sample of each event type for the legend
|
||||
const eventTypes = [
|
||||
MessageChannelEvent.MessageSent,
|
||||
MessageChannelEvent.MessageDelivered,
|
||||
MessageChannelEvent.MessageReceived,
|
||||
MessageChannelEvent.MessageAcknowledged,
|
||||
MessageChannelEvent.PartialAcknowledgement,
|
||||
MessageChannelEvent.MissedMessages
|
||||
];
|
||||
|
||||
// Find one event of each type to display in the legend
|
||||
let legendItems = history.slice(2, 6);
|
||||
|
||||
// Simple tooltip text
|
||||
const leftTooltipText = "Messages are sent between peers in the network. Each message has a unique ID and can depend on other messages. Events like Sent, Received, Delivered and Acknowledged show the status of messages.";
|
||||
|
||||
const rightTooltipText = "Dependencies represent messages that a current message depends on. They appear as smaller boxes below the main message.";
|
||||
|
||||
const lamportTooltipText = "<b>Lamport timestamps</b> provide a way to order events in a distributed system. They increment with each message, ensuring a consistent ordering across all peers in the network, even without synchronized clocks.";
|
||||
|
||||
const eventIdTooltipText = "Unique <b>Message ID</b> assigned to each message.";
|
||||
|
||||
const dependencyContainerTooltipText = "Each message comes with <b>causal history</b> attached, containing the last two message IDs from the local history of the sender.";
|
||||
|
||||
// Custom highlight classes
|
||||
const tooltipHighlightClass = "tooltip-highlight";
|
||||
|
||||
console.log(legendItems);
|
||||
|
||||
// Make isOpen bindable so parent can track when modal is closed
|
||||
export let isOpen = false;
|
||||
|
||||
// Reference elements for tooltips
|
||||
let modalElement: HTMLElement;
|
||||
let leftAnchorElement: HTMLElement;
|
||||
let rightAnchorElement: HTMLElement;
|
||||
let lamportTimestampElement: HTMLElement | null = null;
|
||||
let eventIdElement: HTMLElement | null = null;
|
||||
let dependencyContainerElement: HTMLElement | null = null;
|
||||
// let leftTooltipRef: ReturnType<typeof createTooltip>;
|
||||
// let rightTooltipRef: ReturnType<typeof createTooltip>;
|
||||
let lamportTooltipRef: ReturnType<typeof createTooltip>;
|
||||
let eventIdTooltipRef: ReturnType<typeof createTooltip>;
|
||||
let dependencyContainerTooltipRef: ReturnType<typeof createTooltip>;
|
||||
|
||||
// Handles closing the modal and updates the parent via bindable prop
|
||||
function closeModal() {
|
||||
isOpen = false;
|
||||
}
|
||||
|
||||
// Cleanup tooltips when component is destroyed
|
||||
function cleanupTooltips() {
|
||||
// leftTooltipRef?.destroy();
|
||||
// rightTooltipRef?.destroy();
|
||||
lamportTooltipRef?.destroy();
|
||||
eventIdTooltipRef?.destroy();
|
||||
dependencyContainerTooltipRef?.destroy();
|
||||
}
|
||||
|
||||
// Create tooltips once elements are mounted and modal is open
|
||||
function setupTooltips() {
|
||||
// Clean up any existing tooltips first
|
||||
cleanupTooltips();
|
||||
|
||||
if (!isOpen || !leftAnchorElement || !rightAnchorElement) return;
|
||||
|
||||
// Create left tooltip
|
||||
// leftTooltipRef = createTooltip(leftAnchorElement, {
|
||||
// position: 'left',
|
||||
// content: leftTooltipText,
|
||||
// width: 200,
|
||||
// showOnHover: false,
|
||||
// visible: true,
|
||||
// offset: 20
|
||||
// });
|
||||
|
||||
// Create right tooltip
|
||||
// rightTooltipRef = createTooltip(rightAnchorElement, {
|
||||
// position: 'right',
|
||||
// content: rightTooltipText,
|
||||
// width: 200,
|
||||
// showOnHover: false,
|
||||
// visible: true,
|
||||
// offset: 20
|
||||
// });
|
||||
|
||||
// Wait a moment to find and add tooltip to the specific elements
|
||||
setTimeout(() => {
|
||||
// Find the first lamport timestamp element
|
||||
lamportTimestampElement = document.querySelector('.legend-content .lamport-timestamp');
|
||||
|
||||
if (lamportTimestampElement) {
|
||||
// Create lamport timestamp tooltip
|
||||
lamportTooltipRef = createTooltip(lamportTimestampElement, {
|
||||
position: 'right',
|
||||
content: lamportTooltipText,
|
||||
width: 220,
|
||||
showOnHover: false,
|
||||
visible: true,
|
||||
offset: 10,
|
||||
verticalOffset: -100,
|
||||
highlightTarget: true,
|
||||
highlightClass: tooltipHighlightClass
|
||||
});
|
||||
}
|
||||
|
||||
// Find the first event-id element
|
||||
eventIdElement = document.querySelector('.legend-content .event-id');
|
||||
|
||||
if (eventIdElement) {
|
||||
// Create event-id tooltip
|
||||
eventIdTooltipRef = createTooltip(eventIdElement, {
|
||||
position: 'left',
|
||||
content: eventIdTooltipText,
|
||||
width: 180,
|
||||
showOnHover: false,
|
||||
visible: true,
|
||||
offset: 10,
|
||||
highlightTarget: true,
|
||||
highlightClass: tooltipHighlightClass
|
||||
});
|
||||
}
|
||||
|
||||
// Find the first dependency-container element
|
||||
dependencyContainerElement = document.querySelector('.legend-content .dependency-box');
|
||||
|
||||
if (dependencyContainerElement) {
|
||||
// Create dependency-container tooltip
|
||||
dependencyContainerTooltipRef = createTooltip(dependencyContainerElement, {
|
||||
position: 'left',
|
||||
content: dependencyContainerTooltipText,
|
||||
width: 250,
|
||||
showOnHover: false,
|
||||
visible: true,
|
||||
offset: 20,
|
||||
verticalOffset: 40,
|
||||
highlightTarget: true,
|
||||
highlightClass: tooltipHighlightClass
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (isOpen) {
|
||||
// Need to wait for the DOM to update
|
||||
setTimeout(setupTooltips, 0);
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
cleanupTooltips();
|
||||
});
|
||||
|
||||
// Watch for changes to isOpen state
|
||||
$: if (isOpen) {
|
||||
// Need to wait for the DOM to update
|
||||
setTimeout(setupTooltips, 0);
|
||||
} else {
|
||||
cleanupTooltips();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isOpen}
|
||||
<div class="legend-modal-backdrop" on:click={closeModal}>
|
||||
<div class="legend-container">
|
||||
<!-- Main Modal (Center) -->
|
||||
<div class="legend-modal" bind:this={modalElement} on:click|stopPropagation>
|
||||
<!-- Tooltip anchor elements -->
|
||||
<div class="tooltip-anchor left-anchor" bind:this={leftAnchorElement}></div>
|
||||
<div class="tooltip-anchor right-anchor" bind:this={rightAnchorElement}></div>
|
||||
|
||||
<div class="legend-header">
|
||||
<h2>Legend</h2>
|
||||
<button class="close-button" on:click={closeModal}>×</button>
|
||||
</div>
|
||||
<div class="legend-content">
|
||||
{#each legendItems as event}
|
||||
<div class="legend-item">
|
||||
<HistoryItem
|
||||
{event}
|
||||
identicon={placeholderIdenticon}
|
||||
onEventClick={handleItemClick}
|
||||
onDependencyClick={handleDependencyClick}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.legend-modal-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.legend-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.legend-modal {
|
||||
position: relative;
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
width: 600px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.tooltip-anchor {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 40%;
|
||||
top: 30%;
|
||||
}
|
||||
|
||||
.left-anchor {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.right-anchor {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.legend-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.legend-header h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.legend-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* Custom highlight for lamport timestamp */
|
||||
:global(.tooltip-highlight) {
|
||||
background-color: rgba(253, 230, 138, 0.9) !important;
|
||||
border-radius: 4px !important;
|
||||
box-shadow: 0 0 0 2px #f59e0b !important;
|
||||
color: #000 !important;
|
||||
font-weight: bold !important;
|
||||
animation: pulse 1.5s infinite !important;
|
||||
max-width: none !important;
|
||||
}
|
||||
|
||||
/* Custom highlight for event ID */
|
||||
:global(.event-id-highlight) {
|
||||
background-color: rgba(191, 219, 254, 0.9) !important;
|
||||
border-radius: 4px !important;
|
||||
box-shadow: 0 0 0 2px #3b82f6 !important;
|
||||
color: #000 !important;
|
||||
font-weight: bold !important;
|
||||
animation: pulseCyan 1.5s infinite !important;
|
||||
}
|
||||
|
||||
/* Custom highlight for dependency container */
|
||||
:global(.dependency-container-highlight) {
|
||||
background-color: rgba(220, 252, 231, 0.7) !important;
|
||||
border-radius: 4px !important;
|
||||
box-shadow: 0 0 0 2px #10b981 !important;
|
||||
padding: 4px !important;
|
||||
animation: pulseGreen 1.5s infinite !important;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.7);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 6px rgba(245, 158, 11, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(245, 158, 11, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulseCyan {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.7);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 6px rgba(59, 130, 246, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulseGreen {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 6px rgba(16, 185, 129, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 1200px) {
|
||||
.tooltip-anchor {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -160,8 +160,8 @@
|
||||
font-weight: bold;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
/* overflow: hidden; */
|
||||
/* text-overflow: ellipsis; */
|
||||
}
|
||||
|
||||
.event-id {
|
||||
@ -169,8 +169,8 @@
|
||||
font-size: 10px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
/* overflow: hidden; */
|
||||
/* text-overflow: ellipsis; */
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
129
examples/sds-demo/src/lib/components/Tooltip.svelte
Normal file
129
examples/sds-demo/src/lib/components/Tooltip.svelte
Normal file
@ -0,0 +1,129 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
export let targetElement: HTMLElement | null = null;
|
||||
export let position: 'left' | 'right' = 'right';
|
||||
export let content: string = '';
|
||||
export let offset: number = 20;
|
||||
export let verticalOffset: number = 0;
|
||||
export let width: number = 200;
|
||||
export let showOnHover: boolean = true;
|
||||
export let visible: boolean = false;
|
||||
export let highlightTarget: boolean = false;
|
||||
export let highlightClass: string = 'tooltip-target-highlight';
|
||||
|
||||
let tooltipElement: HTMLElement;
|
||||
let originalTargetClasses: string = '';
|
||||
|
||||
// Position the tooltip relative to the target element
|
||||
function positionTooltip() {
|
||||
if (!targetElement || !tooltipElement) return;
|
||||
|
||||
const targetRect = targetElement.getBoundingClientRect();
|
||||
|
||||
// Calculate vertical center alignment with optional vertical offset
|
||||
const top = targetRect.top + (targetRect.height / 2) + verticalOffset;
|
||||
|
||||
// Position horizontally based on the selected position
|
||||
if (position === 'left') {
|
||||
tooltipElement.style.right = `${window.innerWidth - targetRect.left + offset}px`;
|
||||
} else {
|
||||
tooltipElement.style.left = `${targetRect.right + offset}px`;
|
||||
}
|
||||
|
||||
tooltipElement.style.top = `${top}px`;
|
||||
tooltipElement.style.transform = 'translateY(-50%)';
|
||||
}
|
||||
|
||||
// Reposition on window resize
|
||||
function handleResize() {
|
||||
if (visible) {
|
||||
positionTooltip();
|
||||
}
|
||||
}
|
||||
|
||||
function showTooltip() {
|
||||
visible = true;
|
||||
// Position after becoming visible
|
||||
setTimeout(positionTooltip, 0);
|
||||
}
|
||||
|
||||
function hideTooltip() {
|
||||
visible = false;
|
||||
}
|
||||
|
||||
function applyHighlight() {
|
||||
if (targetElement && highlightTarget) {
|
||||
// Store original classes before adding highlight
|
||||
originalTargetClasses = targetElement.className;
|
||||
targetElement.classList.add(highlightClass);
|
||||
}
|
||||
}
|
||||
|
||||
function removeHighlight() {
|
||||
if (targetElement && highlightTarget) {
|
||||
// If we stored original classes, restore them
|
||||
if (originalTargetClasses) {
|
||||
// Remove highlight class
|
||||
targetElement.classList.remove(highlightClass);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
applyHighlight();
|
||||
|
||||
if (visible) {
|
||||
positionTooltip();
|
||||
}
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
// Ensure we remove highlight when component is destroyed
|
||||
removeHighlight();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if visible}
|
||||
<div
|
||||
class="tooltip {position}-tooltip"
|
||||
bind:this={tooltipElement}
|
||||
style="width: {width}px;"
|
||||
>
|
||||
{@html content}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.tooltip {
|
||||
position: fixed;
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
padding: 16px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: #4b5563;
|
||||
z-index: 1000;
|
||||
max-width: 300px;
|
||||
opacity: 0;
|
||||
animation: fadeIn 0.2s forwards;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.tooltip-target-highlight) {
|
||||
outline: 2px solid rgba(252, 211, 77, 0.8) !important;
|
||||
outline-offset: 2px !important;
|
||||
transition: outline-color 0.2s ease !important;
|
||||
z-index: 2 !important;
|
||||
position: relative !important;
|
||||
}
|
||||
</style>
|
||||
2
examples/sds-demo/src/lib/data/history_sample.ts
Normal file
2
examples/sds-demo/src/lib/data/history_sample.ts
Normal file
File diff suppressed because one or more lines are too long
51
examples/sds-demo/src/lib/data/sample_history.ts
Normal file
51
examples/sds-demo/src/lib/data/sample_history.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { MessageChannelEvent } from '@waku/sds';
|
||||
|
||||
// Sample history with different event types for the legend
|
||||
export const historyJson = JSON.stringify([
|
||||
{
|
||||
type: MessageChannelEvent.MessageSent,
|
||||
payload: {
|
||||
messageId: "db7ce7bff8734cc868da5bd8d880b58765ed9e0481f0c2f6b0ec86258322a3fa",
|
||||
channelId: "channel-id",
|
||||
lamportTimestamp: 6,
|
||||
causalHistory: [],
|
||||
bloomFilter: { 0: 0, 1: 0, 2: 0, 3: 0 },
|
||||
content: { 0: 131, 1: 244 }
|
||||
}
|
||||
},
|
||||
{
|
||||
type: MessageChannelEvent.MessageDelivered,
|
||||
payload: {
|
||||
messageId: "bc8701dd8eacca44f01a177a2d7e2ac879dd189b1f8ea2b57b10bbdb82042bc0",
|
||||
sentOrReceived: "received"
|
||||
}
|
||||
},
|
||||
{
|
||||
type: MessageChannelEvent.MessageReceived,
|
||||
payload: {
|
||||
messageId: "bc8701dd8eacca44f01a177a2d7e2ac879dd189b1f8ea2b57b10bbdb82042bc0",
|
||||
channelId: "channel-id",
|
||||
causalHistory: [],
|
||||
lamportTimestamp: 4,
|
||||
bloomFilter: { 0: 0, 1: 0, 2: 0, 3: 0 },
|
||||
content: { 0: 226, 1: 83 }
|
||||
}
|
||||
},
|
||||
{
|
||||
type: MessageChannelEvent.MessageAcknowledged,
|
||||
payload: "217e647921f9a6fc8ecfc480e207db828e18c5868d229cf5c5bf59be89dc70ff"
|
||||
},
|
||||
{
|
||||
type: MessageChannelEvent.PartialAcknowledgement,
|
||||
payload: {
|
||||
messageId: "d81b2617e63162843413eea8c62dada058ac7e6c8f8463fd5ef1171939cd9415",
|
||||
acknowledgementBitmask: new Uint8Array([1, 0, 1])
|
||||
}
|
||||
},
|
||||
{
|
||||
type: MessageChannelEvent.MissedMessages,
|
||||
payload: {
|
||||
messageIds: ["58cf44867e529152f4095aa6a951500a6a45571a062dc0bddb06aef48c97f85b"]
|
||||
}
|
||||
}
|
||||
]);
|
||||
91
examples/sds-demo/src/lib/utils/tooltipUtils.ts
Normal file
91
examples/sds-demo/src/lib/utils/tooltipUtils.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import { mount, unmount } from 'svelte';
|
||||
import Tooltip from '$lib/components/Tooltip.svelte';
|
||||
|
||||
interface TooltipOptions {
|
||||
position?: 'left' | 'right';
|
||||
content: string;
|
||||
offset?: number;
|
||||
verticalOffset?: number;
|
||||
width?: number;
|
||||
showOnHover?: boolean;
|
||||
visible?: boolean;
|
||||
highlightTarget?: boolean;
|
||||
highlightClass?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a tooltip positioned relative to a target element
|
||||
* @param targetElement - The element to attach the tooltip to
|
||||
* @param options - Configuration options for the tooltip
|
||||
* @returns An object with methods to control the tooltip
|
||||
*/
|
||||
export function createTooltip(targetElement: HTMLElement, options: TooltipOptions) {
|
||||
const {
|
||||
position = 'right',
|
||||
content,
|
||||
offset = 20,
|
||||
width = 200,
|
||||
showOnHover = true,
|
||||
visible = false,
|
||||
highlightTarget = false,
|
||||
highlightClass = 'tooltip-target-highlight',
|
||||
verticalOffset = 0
|
||||
} = options;
|
||||
|
||||
// Store current visibility state
|
||||
let isVisible = visible;
|
||||
|
||||
// Create a container for the tooltip
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
|
||||
// Initialize the tooltip component using Svelte's mount API
|
||||
const tooltipInstance = mount(Tooltip, {
|
||||
target: container,
|
||||
props: {
|
||||
targetElement,
|
||||
position,
|
||||
content,
|
||||
offset,
|
||||
width,
|
||||
verticalOffset,
|
||||
showOnHover,
|
||||
visible: isVisible,
|
||||
highlightTarget,
|
||||
highlightClass
|
||||
}
|
||||
});
|
||||
|
||||
// Return methods to control the tooltip
|
||||
return {
|
||||
destroy: () => {
|
||||
// In Svelte 5, we use the unmount function instead of $destroy
|
||||
unmount(tooltipInstance);
|
||||
container.remove();
|
||||
},
|
||||
updatePosition: () => {
|
||||
tooltipInstance.$set({ targetElement });
|
||||
},
|
||||
updateContent: (newContent: string) => {
|
||||
tooltipInstance.$set({ content: newContent });
|
||||
},
|
||||
show: () => {
|
||||
isVisible = true;
|
||||
tooltipInstance.$set({ visible: true });
|
||||
},
|
||||
hide: () => {
|
||||
isVisible = false;
|
||||
tooltipInstance.$set({ visible: false });
|
||||
},
|
||||
toggle: () => {
|
||||
isVisible = !isVisible;
|
||||
tooltipInstance.$set({ visible: isVisible });
|
||||
},
|
||||
updateOptions: (newOptions: Partial<TooltipOptions>) => {
|
||||
if (newOptions.visible !== undefined) {
|
||||
isVisible = newOptions.visible;
|
||||
}
|
||||
tooltipInstance.$set(newOptions);
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -23,12 +23,14 @@
|
||||
<div class="flex flex-col sm:flex-row gap-4 sm:gap-6 px-4">
|
||||
<div class="flex-1 w-full">
|
||||
<PageLayout title="History" maxWidth="2xl" padding="sm:p-6 px-4" margin="sm:my-6 my-4">
|
||||
<p class="text-gray-600 text-sm mb-4 leading-relaxed">Log of all events as they are emitted by the message channel.</p>
|
||||
<History />
|
||||
</PageLayout>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 w-full">
|
||||
<PageLayout title="Missing" maxWidth="2xl" padding="sm:p-6 px-4" margin="sm:my-6 my-4">
|
||||
<p class="text-gray-600 text-sm mb-4 leading-relaxed">List of messages that are currently known to be missing from the channel.</p>
|
||||
<Missing />
|
||||
</PageLayout>
|
||||
</div>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user