summaryrefslogtreecommitdiff
path: root/ui/lib/state/local/conversations.svelte.js
blob: 835c23717c1f08e534c8afa5a4adbc50d82075b9 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
import { DateTime } from 'luxon';
import { SvelteMap } from 'svelte/reactivity';

import * as iter from '$lib/iterator.js';

// Conversations were called "channels" in previous iterations. Support loading
// data saved under that name to prevent the change from resetting everyone's
// unread tracking.
export const STORE_KEY_CHANNELS = 'pilcrow:channelsData';
export const STORE_KEY_CONVERSATIONS = 'pilcrow:conversations';

class Conversation {
  draft = $state();
  lastReadAt = $state(null);
  scrollPosition = $state(null);

  static fromStored({ draft, lastReadAt, scrollPosition }) {
    return new Conversation({
      draft,
      lastReadAt: lastReadAt == null ? null : DateTime.fromISO(lastReadAt),
      scrollPosition,
    });
  }

  constructor({ draft = '', lastReadAt = null, scrollPosition = null } = {}) {
    this.draft = draft;
    this.lastReadAt = lastReadAt;
    this.scrollPosition = scrollPosition;
  }

  toStored() {
    const { draft, lastReadAt, scrollPosition } = this;
    return {
      draft,
      lastReadAt: lastReadAt?.toISO(),
      scrollPosition,
    };
  }
}

export class Conversations {
  // Store conversationId -> { draft = '', lastReadAt = null, scrollPosition = null }
  all = $state();

  static fromLocalStorage() {
    const stored =
      localStorage.getItem(STORE_KEY_CONVERSATIONS) ?? localStorage.getItem(STORE_KEY_CHANNELS);
    if (stored !== null) {
      return Conversations.fromStored(JSON.parse(stored));
    }
    return Conversations.empty();
  }

  static fromStored(stored) {
    const loaded = Object.keys(stored).map((conversationId) => [
      conversationId,
      Conversation.fromStored(stored[conversationId]),
    ]);
    const all = new SvelteMap(loaded);
    return new Conversations({ all });
  }

  static empty() {
    return new Conversations({ all: new SvelteMap() });
  }

  constructor({ all }) {
    this.all = all;
  }

  conversation(conversationId) {
    let conversation = this.all.get(conversationId);
    if (conversation === undefined) {
      conversation = new Conversation();
      this.all.set(conversationId, conversation);
    }
    return conversation;
  }

  updateLastReadAt(conversationId, at) {
    const conversation = this.conversation(conversationId);
    // Do it this way, rather than with Math.max tricks, to avoid assignment
    // when we don't need it, to minimize reactive changes:
    if (conversation.lastReadAt === null || at > conversation.lastReadAt) {
      conversation.lastReadAt = at;
      this.save();
    }
  }

  retainConversations(conversations) {
    const conversationIds = conversations.map((conversation) => conversation.id);
    const retain = new Set(conversationIds);
    for (const conversationId of Array.from(this.all.keys())) {
      if (!retain.has(conversationId)) {
        this.all.delete(conversationId);
      }
    }
    this.save();
  }

  toStored() {
    return iter.reduce(
      this.all.entries(),
      (stored, [conversationId, conversation]) => ({
        ...stored,
        [conversationId]: conversation.toStored(),
      }),
      {},
    );
  }

  save() {
    let stored = this.toStored();
    localStorage.setItem(STORE_KEY_CONVERSATIONS, JSON.stringify(stored));
    // If we were able to save the data under `pilcrow:conversations`, then remove the old data;
    // it is no longer needed and wouldn't be loaded anyways.
    localStorage.removeItem(STORE_KEY_CHANNELS);
  }
}