atagen

malice

public
Star0
Watch0
7bdad3460b5a85dbf7d1bec2da28c127ba709de9
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git

Blame: web/src/lib/UserCard.svelte

d8624c214c04atagen2026-10-24 08:24 UTC1
<script lang="ts" context="module">
d8624c214c04atagen2026-10-24 08:24 UTC2
  export type UserAction =
d8624c214c04atagen2026-10-24 08:24 UTC3
    | { kind: 'message' | 'mention' | 'unban' | 'mute' | 'unmute' | 'ignore' | 'unignore' }
d8624c214c04atagen2026-10-24 08:24 UTC4
    | { kind: 'kick' | 'ban'; reason: string | null }
d8624c214c04atagen2026-10-24 08:24 UTC5
    | { kind: 'power'; level: number };
d8624c214c04atagen2026-10-24 08:24 UTC6
</script>
d8624c214c04atagen2026-10-24 08:24 UTC7
d8624c214c04atagen2026-10-24 08:24 UTC8
<script lang="ts">
d8624c214c04atagen2026-10-24 08:24 UTC9
  import { onMount, tick } from 'svelte';
d8624c214c04atagen2026-10-24 08:24 UTC10
  import MxcAvatar from './MxcAvatar.svelte';
445bf199a9cfatagen2026-10-25 02:56 UTC11
  import EmojiText from './EmojiText.svelte';
d8624c214c04atagen2026-10-24 08:24 UTC12
  import { allowed, isMuted, levelOf, roleName, type PowerLevels } from './permissions';
d8624c214c04atagen2026-10-24 08:24 UTC13
  import type { MemberSummary, Person } from './types';
d8624c214c04atagen2026-10-24 08:24 UTC14
d8624c214c04atagen2026-10-24 08:24 UTC15
  export let person: Person;
d8624c214c04atagen2026-10-24 08:24 UTC16
  export let member: MemberSummary | null = null;
d8624c214c04atagen2026-10-24 08:24 UTC17
  export let levels: PowerLevels | null = null;
d8624c214c04atagen2026-10-24 08:24 UTC18
  export let self: string | null = null;
d8624c214c04atagen2026-10-24 08:24 UTC19
  export let x: number;
d8624c214c04atagen2026-10-24 08:24 UTC20
  export let y: number;
d8624c214c04atagen2026-10-24 08:24 UTC21
  export let confirm: 'kick' | 'ban' | null = null;
d8624c214c04atagen2026-10-24 08:24 UTC22
  export let onAction: (action: UserAction) => void;
d8624c214c04atagen2026-10-24 08:24 UTC23
  export let onClose: () => void;
d8624c214c04atagen2026-10-24 08:24 UTC24
d8624c214c04atagen2026-10-24 08:24 UTC25
  const MARGIN = 8;
d8624c214c04atagen2026-10-24 08:24 UTC26
d8624c214c04atagen2026-10-24 08:24 UTC27
  let card: HTMLDivElement;
d8624c214c04atagen2026-10-24 08:24 UTC28
  let left = x;
d8624c214c04atagen2026-10-24 08:24 UTC29
  let top = y;
d8624c214c04atagen2026-10-24 08:24 UTC30
  let reason = '';
d8624c214c04atagen2026-10-24 08:24 UTC31
  let copied = false;
d8624c214c04atagen2026-10-24 08:24 UTC32
d8624c214c04atagen2026-10-24 08:24 UTC33
  $: own = person.user_id === self;
d8624c214c04atagen2026-10-24 08:24 UTC34
  $: level = levels ? levelOf(levels, person.user_id) : null;
d8624c214c04atagen2026-10-24 08:24 UTC35
  $: role = level !== null ? roleName(level) : null;
d8624c214c04atagen2026-10-24 08:24 UTC36
  $: muted = levels ? isMuted(levels, person.user_id) : false;
d8624c214c04atagen2026-10-24 08:24 UTC37
  $: can = levels && self ? allowed(levels, self, person.user_id) : null;
d8624c214c04atagen2026-10-24 08:24 UTC38
  $: banned = member?.membership === 'Ban';
d8624c214c04atagen2026-10-24 08:24 UTC39
  $: powerInput = level !== null && Number.isFinite(level) ? String(level) : '';
d8624c214c04atagen2026-10-24 08:24 UTC40
d8624c214c04atagen2026-10-24 08:24 UTC41
  onMount(async () => {
d8624c214c04atagen2026-10-24 08:24 UTC42
    await tick();
d8624c214c04atagen2026-10-24 08:24 UTC43
    const rect = card.getBoundingClientRect();
d8624c214c04atagen2026-10-24 08:24 UTC44
    left = Math.max(MARGIN, Math.min(x, window.innerWidth - rect.width - MARGIN));
d8624c214c04atagen2026-10-24 08:24 UTC45
    top = Math.max(MARGIN, Math.min(y, window.innerHeight - rect.height - MARGIN));
d8624c214c04atagen2026-10-24 08:24 UTC46
  });
d8624c214c04atagen2026-10-24 08:24 UTC47
d8624c214c04atagen2026-10-24 08:24 UTC48
  function act(action: UserAction): void {
d8624c214c04atagen2026-10-24 08:24 UTC49
    onClose();
d8624c214c04atagen2026-10-24 08:24 UTC50
    onAction(action);
d8624c214c04atagen2026-10-24 08:24 UTC51
  }
d8624c214c04atagen2026-10-24 08:24 UTC52
d8624c214c04atagen2026-10-24 08:24 UTC53
  function setPower(): void {
d8624c214c04atagen2026-10-24 08:24 UTC54
    const value = Number(powerInput);
d8624c214c04atagen2026-10-24 08:24 UTC55
    if (!Number.isInteger(value) || value === level) return;
d8624c214c04atagen2026-10-24 08:24 UTC56
    act({ kind: 'power', level: value });
d8624c214c04atagen2026-10-24 08:24 UTC57
  }
d8624c214c04atagen2026-10-24 08:24 UTC58
d8624c214c04atagen2026-10-24 08:24 UTC59
  async function copyId(): Promise<void> {
d8624c214c04atagen2026-10-24 08:24 UTC60
    try {
d8624c214c04atagen2026-10-24 08:24 UTC61
      await navigator.clipboard.writeText(person.user_id);
d8624c214c04atagen2026-10-24 08:24 UTC62
      copied = true;
d8624c214c04atagen2026-10-24 08:24 UTC63
      window.setTimeout(() => (copied = false), 1500);
d8624c214c04atagen2026-10-24 08:24 UTC64
    } catch {
d8624c214c04atagen2026-10-24 08:24 UTC65
      copied = false;
d8624c214c04atagen2026-10-24 08:24 UTC66
    }
d8624c214c04atagen2026-10-24 08:24 UTC67
  }
d8624c214c04atagen2026-10-24 08:24 UTC68
d8624c214c04atagen2026-10-24 08:24 UTC69
  function outside(ev: PointerEvent): void {
d8624c214c04atagen2026-10-24 08:24 UTC70
    if (!card.contains(ev.target as Node)) onClose();
d8624c214c04atagen2026-10-24 08:24 UTC71
  }
d8624c214c04atagen2026-10-24 08:24 UTC72
d8624c214c04atagen2026-10-24 08:24 UTC73
  function key(ev: KeyboardEvent): void {
d8624c214c04atagen2026-10-24 08:24 UTC74
    if (ev.key !== 'Escape') return;
d8624c214c04atagen2026-10-24 08:24 UTC75
    ev.stopPropagation();
d8624c214c04atagen2026-10-24 08:24 UTC76
    if (confirm) confirm = null;
d8624c214c04atagen2026-10-24 08:24 UTC77
    else onClose();
d8624c214c04atagen2026-10-24 08:24 UTC78
  }
d8624c214c04atagen2026-10-24 08:24 UTC79
</script>
d8624c214c04atagen2026-10-24 08:24 UTC80
d8624c214c04atagen2026-10-24 08:24 UTC81
<svelte:window on:pointerdown|capture={outside} on:keydown|capture={key} on:resize={onClose} />
d8624c214c04atagen2026-10-24 08:24 UTC82
d8624c214c04atagen2026-10-24 08:24 UTC83
<div class="user-card" role="dialog" aria-label={person.name} style={`left:${left}px;top:${top}px`} bind:this={card}>
d8624c214c04atagen2026-10-24 08:24 UTC84
  <div class="identity">
05a48e9e7942atagen2026-10-24 09:32 UTC85
    <MxcAvatar mxc={person.avatar} label={person.name} seed={person.user_id} size={56} />
d8624c214c04atagen2026-10-24 08:24 UTC86
    <div class="names">
445bf199a9cfatagen2026-10-25 02:56 UTC87
      <strong class="name"><EmojiText text={person.name} /></strong>
d8624c214c04atagen2026-10-24 08:24 UTC88
      <button type="button" class="user-id" title="Copy user ID" on:click={() => void copyId()}>
d8624c214c04atagen2026-10-24 08:24 UTC89
        {copied ? 'Copied' : person.user_id}
d8624c214c04atagen2026-10-24 08:24 UTC90
      </button>
d8624c214c04atagen2026-10-24 08:24 UTC91
      <div class="tags">
d8624c214c04atagen2026-10-24 08:24 UTC92
        {#if role}<span class="tag">{role}</span>{/if}
d8624c214c04atagen2026-10-24 08:24 UTC93
        {#if level !== null && Number.isFinite(level) && level !== 0}<span class="tag">Power {level}</span>{/if}
d8624c214c04atagen2026-10-24 08:24 UTC94
        {#if muted}<span class="tag warn">Muted</span>{/if}
d8624c214c04atagen2026-10-24 08:24 UTC95
        {#if banned}<span class="tag danger">Banned</span>{/if}
d8624c214c04atagen2026-10-24 08:24 UTC96
        {#if member?.ignored}<span class="tag">Ignored</span>{/if}
d8624c214c04atagen2026-10-24 08:24 UTC97
      </div>
d8624c214c04atagen2026-10-24 08:24 UTC98
    </div>
d8624c214c04atagen2026-10-24 08:24 UTC99
  </div>
d8624c214c04atagen2026-10-24 08:24 UTC100
d8624c214c04atagen2026-10-24 08:24 UTC101
  {#if confirm}
d8624c214c04atagen2026-10-24 08:24 UTC102
    <form class="confirm" on:submit|preventDefault={() => confirm && act({ kind: confirm, reason: reason.trim() || null })}>
d8624c214c04atagen2026-10-24 08:24 UTC103
      <!-- svelte-ignore a11y_autofocus -->
d8624c214c04atagen2026-10-24 08:24 UTC104
      <input type="text" placeholder="Reason" bind:value={reason} autofocus />
d8624c214c04atagen2026-10-24 08:24 UTC105
      <div class="row">
d8624c214c04atagen2026-10-24 08:24 UTC106
        <button type="button" class="ghost-btn" on:click={() => (confirm = null)}>Cancel</button>
445bf199a9cfatagen2026-10-25 02:56 UTC107
        <button type="submit" class="ghost-btn danger">{confirm === 'kick' ? 'Kick' : 'Ban'} <EmojiText text={person.name} /></button>
d8624c214c04atagen2026-10-24 08:24 UTC108
      </div>
d8624c214c04atagen2026-10-24 08:24 UTC109
    </form>
d8624c214c04atagen2026-10-24 08:24 UTC110
  {:else}
d8624c214c04atagen2026-10-24 08:24 UTC111
    <div class="row">
d8624c214c04atagen2026-10-24 08:24 UTC112
      {#if !own}
d8624c214c04atagen2026-10-24 08:24 UTC113
        <button type="button" class="ghost-btn" on:click={() => act({ kind: 'message' })}>Message</button>
d8624c214c04atagen2026-10-24 08:24 UTC114
      {/if}
d8624c214c04atagen2026-10-24 08:24 UTC115
      <button type="button" class="ghost-btn" on:click={() => act({ kind: 'mention' })}>Mention</button>
d8624c214c04atagen2026-10-24 08:24 UTC116
    </div>
d8624c214c04atagen2026-10-24 08:24 UTC117
d8624c214c04atagen2026-10-24 08:24 UTC118
    {#if can && (can.kick || can.ban || can.setLevel)}
d8624c214c04atagen2026-10-24 08:24 UTC119
      <div class="row">
d8624c214c04atagen2026-10-24 08:24 UTC120
        {#if can.setLevel && !own}
d8624c214c04atagen2026-10-24 08:24 UTC121
          <button type="button" class="ghost-btn" on:click={() => act({ kind: muted ? 'unmute' : 'mute' })}>
d8624c214c04atagen2026-10-24 08:24 UTC122
            {muted ? 'Unmute' : 'Mute'}
d8624c214c04atagen2026-10-24 08:24 UTC123
          </button>
d8624c214c04atagen2026-10-24 08:24 UTC124
        {/if}
d8624c214c04atagen2026-10-24 08:24 UTC125
        {#if can.kick && !banned && member && member.membership !== 'Leave'}
d8624c214c04atagen2026-10-24 08:24 UTC126
          <button type="button" class="ghost-btn danger" on:click={() => (confirm = 'kick')}>Kick</button>
d8624c214c04atagen2026-10-24 08:24 UTC127
        {/if}
d8624c214c04atagen2026-10-24 08:24 UTC128
        {#if can.ban}
d8624c214c04atagen2026-10-24 08:24 UTC129
          {#if banned}
d8624c214c04atagen2026-10-24 08:24 UTC130
            <button type="button" class="ghost-btn" on:click={() => act({ kind: 'unban' })}>Unban</button>
d8624c214c04atagen2026-10-24 08:24 UTC131
          {:else}
d8624c214c04atagen2026-10-24 08:24 UTC132
            <button type="button" class="ghost-btn danger" on:click={() => (confirm = 'ban')}>Ban</button>
d8624c214c04atagen2026-10-24 08:24 UTC133
          {/if}
d8624c214c04atagen2026-10-24 08:24 UTC134
        {/if}
d8624c214c04atagen2026-10-24 08:24 UTC135
      </div>
d8624c214c04atagen2026-10-24 08:24 UTC136
      {#if can.setLevel}
d8624c214c04atagen2026-10-24 08:24 UTC137
        <form class="row power" on:submit|preventDefault={setPower}>
d8624c214c04atagen2026-10-24 08:24 UTC138
          <label for="power-level">Power level</label>
d8624c214c04atagen2026-10-24 08:24 UTC139
          <input id="power-level" type="number" step="1" max={Number.isFinite(can.maxLevel) ? can.maxLevel : undefined} bind:value={powerInput} />
d8624c214c04atagen2026-10-24 08:24 UTC140
          <button type="submit" class="ghost-btn" disabled={powerInput === '' || Number(powerInput) === level}>Set</button>
d8624c214c04atagen2026-10-24 08:24 UTC141
        </form>
d8624c214c04atagen2026-10-24 08:24 UTC142
      {/if}
d8624c214c04atagen2026-10-24 08:24 UTC143
    {/if}
d8624c214c04atagen2026-10-24 08:24 UTC144
d8624c214c04atagen2026-10-24 08:24 UTC145
    {#if !own}
d8624c214c04atagen2026-10-24 08:24 UTC146
      <div class="row">
d8624c214c04atagen2026-10-24 08:24 UTC147
        <button type="button" class="ghost-btn subtle" on:click={() => act({ kind: member?.ignored ? 'unignore' : 'ignore' })}>
d8624c214c04atagen2026-10-24 08:24 UTC148
          {member?.ignored ? 'Unignore' : 'Ignore'}
d8624c214c04atagen2026-10-24 08:24 UTC149
        </button>
d8624c214c04atagen2026-10-24 08:24 UTC150
      </div>
d8624c214c04atagen2026-10-24 08:24 UTC151
    {/if}
d8624c214c04atagen2026-10-24 08:24 UTC152
  {/if}
d8624c214c04atagen2026-10-24 08:24 UTC153
</div>
d8624c214c04atagen2026-10-24 08:24 UTC154
d8624c214c04atagen2026-10-24 08:24 UTC155
<style>
d8624c214c04atagen2026-10-24 08:24 UTC156
  .user-card {
05a48e9e7942atagen2026-10-24 09:32 UTC157
    animation: pop-in var(--pop);
d8624c214c04atagen2026-10-24 08:24 UTC158
    position: fixed;
d8624c214c04atagen2026-10-24 08:24 UTC159
    z-index: 90;
d8624c214c04atagen2026-10-24 08:24 UTC160
    width: 300px;
d8624c214c04atagen2026-10-24 08:24 UTC161
    max-width: calc(100vw - 16px);
d8624c214c04atagen2026-10-24 08:24 UTC162
    display: flex;
d8624c214c04atagen2026-10-24 08:24 UTC163
    flex-direction: column;
d8624c214c04atagen2026-10-24 08:24 UTC164
    gap: 10px;
d8624c214c04atagen2026-10-24 08:24 UTC165
    padding: 14px;
d8624c214c04atagen2026-10-24 08:24 UTC166
    background: var(--panel-2);
d8624c214c04atagen2026-10-24 08:24 UTC167
    border: 1px solid var(--border);
05a48e9e7942atagen2026-10-24 09:32 UTC168
    border-radius: var(--r-md);
d8624c214c04atagen2026-10-24 08:24 UTC169
    box-shadow: 0 12px 32px rgb(0 0 0 / 0.4);
d8624c214c04atagen2026-10-24 08:24 UTC170
  }
d8624c214c04atagen2026-10-24 08:24 UTC171
  .identity { display: flex; gap: 12px; align-items: center; }
d8624c214c04atagen2026-10-24 08:24 UTC172
  .names { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
d8624c214c04atagen2026-10-24 08:24 UTC173
  .name { font-size: 16px; overflow-wrap: anywhere; }
d8624c214c04atagen2026-10-24 08:24 UTC174
  .user-id {
d8624c214c04atagen2026-10-24 08:24 UTC175
    padding: 0;
d8624c214c04atagen2026-10-24 08:24 UTC176
    border: none;
d8624c214c04atagen2026-10-24 08:24 UTC177
    background: none;
d8624c214c04atagen2026-10-24 08:24 UTC178
    color: var(--muted);
d8624c214c04atagen2026-10-24 08:24 UTC179
    font: inherit;
d8624c214c04atagen2026-10-24 08:24 UTC180
    font-size: 12.5px;
d8624c214c04atagen2026-10-24 08:24 UTC181
    text-align: left;
d8624c214c04atagen2026-10-24 08:24 UTC182
    cursor: copy;
d8624c214c04atagen2026-10-24 08:24 UTC183
    overflow-wrap: anywhere;
d8624c214c04atagen2026-10-24 08:24 UTC184
  }
d8624c214c04atagen2026-10-24 08:24 UTC185
  .user-id:hover { color: var(--text); }
d8624c214c04atagen2026-10-24 08:24 UTC186
  .tags { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 2px; }
d8624c214c04atagen2026-10-24 08:24 UTC187
  .tag {
d8624c214c04atagen2026-10-24 08:24 UTC188
    font-size: 11px;
d8624c214c04atagen2026-10-24 08:24 UTC189
    padding: 1px 6px;
05a48e9e7942atagen2026-10-24 09:32 UTC190
    border-radius: var(--r-sm);
d8624c214c04atagen2026-10-24 08:24 UTC191
    border: 1px solid var(--border);
d8624c214c04atagen2026-10-24 08:24 UTC192
    color: var(--muted);
d8624c214c04atagen2026-10-24 08:24 UTC193
  }
d8624c214c04atagen2026-10-24 08:24 UTC194
  .tag.warn { color: var(--warn-text); border-color: var(--warn-border); }
d8624c214c04atagen2026-10-24 08:24 UTC195
  .tag.danger { color: var(--danger); border-color: var(--danger-border); }
d8624c214c04atagen2026-10-24 08:24 UTC196
  .row { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
d8624c214c04atagen2026-10-24 08:24 UTC197
  .row > :global(.ghost-btn) { flex: 1; }
d8624c214c04atagen2026-10-24 08:24 UTC198
  .power label { font-size: 12.5px; color: var(--muted); }
d8624c214c04atagen2026-10-24 08:24 UTC199
  .power input { width: 80px; flex: 1; }
d8624c214c04atagen2026-10-24 08:24 UTC200
  input {
d8624c214c04atagen2026-10-24 08:24 UTC201
    background: var(--bg-soft);
d8624c214c04atagen2026-10-24 08:24 UTC202
    border: 1px solid var(--border);
05a48e9e7942atagen2026-10-24 09:32 UTC203
    border-radius: var(--r-sm);
d8624c214c04atagen2026-10-24 08:24 UTC204
    color: var(--text);
d8624c214c04atagen2026-10-24 08:24 UTC205
    padding: 5px 8px;
d8624c214c04atagen2026-10-24 08:24 UTC206
    font: inherit;
d8624c214c04atagen2026-10-24 08:24 UTC207
    font-size: 13px;
d8624c214c04atagen2026-10-24 08:24 UTC208
  }
d8624c214c04atagen2026-10-24 08:24 UTC209
  .confirm { display: flex; flex-direction: column; gap: 8px; }
d8624c214c04atagen2026-10-24 08:24 UTC210
  .subtle { color: var(--muted); }
d8624c214c04atagen2026-10-24 08:24 UTC211
</style>