slk 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,6 +10,10 @@ module Slk
10
10
  class Status < Base
11
11
  include Support::InlineImages
12
12
 
13
+ SCHEDULE_USAGE = 'Usage: slk status schedule "<text>" [:emoji:] <start-end> | --start WHEN [--end WHEN]'
14
+ MISSING_RANGE = 'Missing time range. Example: slk status schedule "Vet Appt" :paw_prints: 1:30p-3:30p ' \
15
+ '(or --start/--end for a multi-day window).'
16
+
13
17
  def execute
14
18
  result = validate_options
15
19
  return result if result
@@ -22,6 +26,9 @@ module Slk
22
26
 
23
27
  def dispatch_action
24
28
  case positional_args
29
+ in ['schedule', *rest] then schedule_status(rest)
30
+ in ['scheduled', *] then list_scheduled
31
+ in ['unschedule', *rest] then unschedule_status(rest)
25
32
  in ['clear', *] then clear_status
26
33
  in [text, *rest] then set_status(text, rest)
27
34
  in [] then get_status
@@ -31,17 +38,17 @@ module Slk
31
38
  protected
32
39
 
33
40
  def default_options
34
- super.merge(presence: nil, dnd: nil)
41
+ super.merge(presence: nil, dnd: nil, with_dnd: false, start_at: nil, end_at: nil)
35
42
  end
36
43
 
37
44
  def handle_option(arg, args, remaining)
38
45
  case arg
39
- when '-p', '--presence'
40
- @options[:presence] = args.shift
41
- when '-d', '--dnd'
42
- @options[:dnd] = args.shift
43
- else
44
- super
46
+ when '-p', '--presence' then @options[:presence] = option_value(arg, args)
47
+ when '-d', '--dnd' then @options[:dnd] = option_value(arg, args)
48
+ when '--with-dnd' then @options[:with_dnd] = true
49
+ when '--start' then @options[:start_at] = option_value(arg, args)
50
+ when '--end' then @options[:end_at] = option_value(arg, args)
51
+ else super
45
52
  end
46
53
  end
47
54
 
@@ -49,7 +56,10 @@ module Slk
49
56
  help = Support::HelpFormatter.new('slk status [text] [emoji] [duration] [options]')
50
57
  help.description('Get or set your Slack status.')
51
58
  help.note('GET shows all workspaces by default. SET applies to primary only.')
59
+ help.note('scheduled shows all workspaces; schedule applies to primary; unschedule finds the ID owner.')
60
+ help.note('Slack allows at most 5 scheduled statuses at a time.')
52
61
  add_examples_section(help)
62
+ add_scheduling_section(help)
53
63
  add_options_section(help)
54
64
  help.render
55
65
  end
@@ -64,17 +74,42 @@ module Slk
64
74
  end
65
75
  end
66
76
 
77
+ def add_scheduling_section(help)
78
+ help.section('SCHEDULING') do |s|
79
+ s.example('slk status schedule "Vet Appt" :paw_prints: 1:30p-3:30p', 'Schedule for later')
80
+ s.example('slk status schedule "OOO" :palm_tree: 2026-08-04 9:00-17:00')
81
+ s.example('slk status schedule "OOO" :palm_tree: --start "2026-08-12 8a" --end "2026-08-14 5p"',
82
+ 'Span multiple days')
83
+ s.example('slk status schedule "Heads down" :no_bell: --start 2p', 'No end; stays until cleared')
84
+ s.example('slk status scheduled', 'List pending scheduled statuses')
85
+ s.example('slk status unschedule CS0BMQDDGWTU', 'Cancel a scheduled status')
86
+ end
87
+ end
88
+
67
89
  def add_options_section(help)
68
90
  help.section('OPTIONS') do |s|
69
- s.option('-p, --presence VALUE', 'Also set presence (away/auto/active)')
70
- s.option('-d, --dnd DURATION', "Also set DND (or 'off')")
71
- s.option('-w, --workspace', 'Limit to specific workspace')
72
- s.option('--all', 'Set across all workspaces')
73
- s.option('-v, --verbose', 'Show debug information')
74
- s.option('-q, --quiet', 'Suppress output')
91
+ add_general_options(s)
92
+ add_scheduling_options(s)
75
93
  end
76
94
  end
77
95
 
96
+ def add_general_options(section)
97
+ section.option('-p, --presence VALUE', 'Also set presence (away/auto/active)')
98
+ section.option('-d, --dnd DURATION', "Also set DND (or 'off')")
99
+ section.option('-w, --workspace', 'Limit to specific workspace')
100
+ section.option('--all', 'Set across all workspaces')
101
+ section.option('-v, --verbose', 'Show debug information')
102
+ section.option('-q, --quiet', 'Suppress output')
103
+ end
104
+
105
+ # These are ignored outside `schedule`, so say so rather than leaving
106
+ # --with-dnd looking like a sibling of -d/--dnd.
107
+ def add_scheduling_options(section)
108
+ section.option('--with-dnd', 'Scheduling only: pause notifications while the status is active')
109
+ section.option('--start WHEN', 'Scheduling only: window start, "[YYYY-MM-DD ]TIME"')
110
+ section.option('--end WHEN', 'Scheduling only: window end; omit for no expiry')
111
+ end
112
+
78
113
  private
79
114
 
80
115
  def get_status # rubocop:disable Naming/AccessorMethodName
@@ -210,6 +245,182 @@ module Slk
210
245
  0
211
246
  end
212
247
 
248
+ def schedule_status(args)
249
+ text, *rest = args
250
+ return error(SCHEDULE_USAGE) if text.to_s.strip.empty?
251
+
252
+ window = parse_schedule_window(rest)
253
+ return 1 unless window
254
+
255
+ result = create_scheduled_status(text, extract_emoji(rest), *window)
256
+ show_all_workspaces_hint
257
+ result
258
+ end
259
+
260
+ # Narrow on both axes: only the parsing is covered, so a Slack failure is
261
+ # not reported as a mistyped range, and only TimeFormatError is caught, so
262
+ # an arity or range error from this code does not surface as user error.
263
+ def parse_schedule_window(rest)
264
+ return flag_window(rest) if @options[:start_at] || @options[:end_at]
265
+
266
+ range = extract_time_range(rest)
267
+ return reject_window(MISSING_RANGE) unless range
268
+
269
+ Support::TimeRangeParser.parse(range)
270
+ rescue TimeFormatError => e
271
+ error(e.message)
272
+ nil
273
+ end
274
+
275
+ # --start/--end is the general form. Both times carry their own date, so
276
+ # it reaches multi-day windows the positional range cannot express, and
277
+ # omitting --end schedules a status that never auto-clears.
278
+ def flag_window(rest)
279
+ return reject_window('--end requires --start.') unless @options[:start_at]
280
+ return reject_window('Use either a time range or --start/--end, not both.') if extract_time_range(rest)
281
+
282
+ starts_at = Support::TimeParser.parse(@options[:start_at])
283
+ return [starts_at, nil] unless @options[:end_at]
284
+
285
+ [starts_at, validated_end(starts_at)]
286
+ end
287
+
288
+ def validated_end(starts_at)
289
+ ends_at = Support::TimeParser.parse(@options[:end_at])
290
+ # An explicit end is unambiguous, so there is nothing to roll forward.
291
+ return ends_at if ends_at > starts_at
292
+
293
+ raise TimeFormatError, "--end #{@options[:end_at]} is not after --start #{@options[:start_at]}."
294
+ end
295
+
296
+ # Returns nil so the caller reads it as "no window, already reported".
297
+ def reject_window(message)
298
+ error(message)
299
+ nil
300
+ end
301
+
302
+ # The range may arrive as one token ("1:30p-3:30p") or several
303
+ # ("2026-08-04 13:30-15:30"), so the non-emoji arguments are matched as a
304
+ # whole. Emoji are dropped first because the pattern is \A-anchored and a
305
+ # leading ":palm_tree:" would stop the date form from matching at all.
306
+ #
307
+ # Everything left has to be part of the range. Only YYYY-MM-DD dates
308
+ # parse, so picking the range out and discarding "8/4" or "tomorrow"
309
+ # would silently schedule the status for today.
310
+ def extract_time_range(rest)
311
+ candidates = rest.reject { |arg| arg.start_with?(':') && arg.end_with?(':') }
312
+ return nil if candidates.empty?
313
+
314
+ joined = candidates.join(' ')
315
+ return joined if Support::TimeRangeParser.match?(joined)
316
+
317
+ raise TimeFormatError, "Unrecognized argument: #{joined}. Use #{Support::TimeRangeParser::EXAMPLE}"
318
+ end
319
+
320
+ # Each workspace is an independent call, so one failure must not strand
321
+ # the workspaces after it: the user would re-run to fix the tail and
322
+ # double-schedule everything that already succeeded.
323
+ def each_workspace_reporting(workspaces)
324
+ failed = false
325
+
326
+ workspaces.each do |workspace|
327
+ yield workspace
328
+ rescue ApiError => e
329
+ failed = true
330
+ error("#{workspace.name}: #{e.message}")
331
+ end
332
+
333
+ failed ? 1 : 0
334
+ end
335
+
336
+ def create_scheduled_status(text, emoji, starts_at, ends_at)
337
+ each_workspace_reporting(target_workspaces) do |workspace|
338
+ scheduled = runner.custom_status_api(workspace.name).schedule(
339
+ text: text, emoji: emoji,
340
+ date_scheduled: starts_at, date_expire: ends_at,
341
+ dnd: @options[:with_dnd]
342
+ )
343
+ success("Scheduled on #{workspace.name}: #{scheduled}")
344
+ debug(" ID: #{scheduled.id}")
345
+ end
346
+ end
347
+
348
+ def list_scheduled
349
+ workspaces = target_workspaces_for_get
350
+
351
+ each_workspace_reporting(workspaces) do |workspace|
352
+ puts output.bold(workspace.name) if workspaces.size > 1
353
+ print_scheduled(runner.custom_status_api(workspace.name).scheduled)
354
+ end
355
+ end
356
+
357
+ def print_scheduled(scheduled)
358
+ return puts ' (none scheduled)' if scheduled.empty?
359
+
360
+ scheduled.each { |status| puts " #{status.id} #{status}" }
361
+ end
362
+
363
+ def unschedule_status(args)
364
+ id = args.first
365
+ return error('Usage: slk status unschedule <id>') if id.to_s.strip.empty?
366
+
367
+ @unchecked = []
368
+ targets = unschedule_targets(id)
369
+ return report_id_not_found(id) if targets.empty?
370
+
371
+ each_workspace_reporting(targets) do |workspace|
372
+ outcome, reason = runner.custom_status_api(workspace.name).delete_scheduled(id)
373
+ report_cancelled(workspace, outcome, reason)
374
+ end
375
+ end
376
+
377
+ # The delete succeeded either way, so both are successes — but only one
378
+ # of them has been checked, and saying so is the difference between the
379
+ # user moving on and the user re-cancelling something already gone.
380
+ def report_cancelled(workspace, outcome, reason)
381
+ return success("Cancelled scheduled status on #{workspace.name}") if outcome == :cancelled
382
+
383
+ warn("Cancelled scheduled status on #{workspace.name}, but could not confirm it: #{reason}")
384
+ end
385
+
386
+ # `slk status scheduled` lists every workspace, so the obvious next step
387
+ # is to paste an ID straight back in. Defaulting to the primary workspace
388
+ # made that fail with a bare Slack error whenever the ID came from
389
+ # another one, so look up the owner instead. An explicit -w/--all still
390
+ # wins, and a lone workspace needs no lookup.
391
+ def unschedule_targets(id)
392
+ return target_workspaces if @options[:all] || @options[:workspace]
393
+
394
+ workspaces = runner.all_workspaces
395
+ return workspaces if workspaces.size <= 1
396
+
397
+ [workspaces.find { |workspace| owns_scheduled?(workspace, id) }].compact
398
+ end
399
+
400
+ # "Not found" is only true of the workspaces we actually reached. Saying
401
+ # it flatly after warning that one could not be checked contradicts the
402
+ # warning, and pointing at `slk status scheduled` would just fail the
403
+ # same way — it calls the same endpoint.
404
+ def report_id_not_found(id)
405
+ return error("No scheduled status #{id} found. Run 'slk status scheduled' to list IDs.") if @unchecked.empty?
406
+
407
+ error("#{id} was not found, but #{@unchecked.join(', ')} could not be checked. " \
408
+ 'Retry, or name the workspace with -w to cancel it directly.')
409
+ end
410
+
411
+ def owns_scheduled?(workspace, id)
412
+ runner.custom_status_api(workspace.name).scheduled.any? { |status| status.id == id }
413
+ rescue RateLimitError
414
+ # Every remaining check spends another call against the same limit, so
415
+ # continuing turns one rate limit into several and still cannot answer.
416
+ raise
417
+ rescue ApiError => e
418
+ # Skipping it silently would report "not found" for an ID that exists.
419
+ warn("Could not check #{workspace.name}: #{e.message}")
420
+ @unchecked << workspace.name
421
+ false
422
+ end
423
+
213
424
  def show_all_workspaces_hint
214
425
  # Show hint if user has multiple workspaces and didn't use --all or -w
215
426
  return if @options[:all] || @options[:workspace]
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Commands
5
+ # Display a Slack user profile (teems-style compact card by default).
6
+ # Examples:
7
+ # slk who # self
8
+ # slk who @alex
9
+ # slk who alice
10
+ # slk who Uxxx --full
11
+ # slk who --json
12
+ class Who < Base
13
+ def execute
14
+ result = validate_options
15
+ return result if result
16
+
17
+ run
18
+ rescue ApiError => e
19
+ error("API error: #{e.message}")
20
+ 1
21
+ end
22
+
23
+ protected
24
+
25
+ def handle_option(arg, args, _remaining)
26
+ case arg
27
+ when '--full' then @options[:full] = true
28
+ when '--no-cache', '--refresh' then @options[:refresh] = true
29
+ when '--all' then @options[:all] = true
30
+ when '--pick' then @options[:pick] = parse_pick(args.shift)
31
+ else return super
32
+ end
33
+ true
34
+ end
35
+
36
+ def help_text
37
+ <<~HELP
38
+ slk who [target]
39
+
40
+ Show a Slack user profile.
41
+
42
+ TARGET
43
+ (none) Self
44
+ @handle | name Resolved via user cache
45
+ Uxxx Raw user ID
46
+
47
+ OPTIONS
48
+ --full Section-grouped layout (Contact, People, About me)
49
+ --json Raw JSON output
50
+ --refresh Bypass cache
51
+ --all Render every match (skips the disambiguation prompt)
52
+ --pick N Auto-select match N when a name resolves to multiple
53
+ HELP
54
+ end
55
+
56
+ private
57
+
58
+ def parse_pick(value)
59
+ Integer(value)
60
+ rescue ArgumentError, TypeError
61
+ raise ApiError, "--pick expects an integer (got #{value.inspect})"
62
+ end
63
+
64
+ def run
65
+ workspace = runner.workspace(@options[:workspace])
66
+ profiles = resolve_profiles(workspace)
67
+ return output_json_profiles(profiles) if @options[:json]
68
+
69
+ render_profiles(profiles)
70
+ 0
71
+ end
72
+
73
+ def resolve_profiles(workspace)
74
+ resolver = Services::WhoTargetResolver.new(
75
+ workspace: workspace, cache_store: cache_store,
76
+ api_client: api_client, output: output, options: @options
77
+ )
78
+ resolver.resolve(positional_args.first).map { |id| load_profile(workspace, id) }
79
+ end
80
+
81
+ def load_profile(workspace, user_id)
82
+ runner.profile_resolver(workspace.name, refresh: @options[:refresh])
83
+ .resolve_with_people(user_id)
84
+ end
85
+
86
+ def render_profiles(profiles)
87
+ profiles.each_with_index do |profile, idx|
88
+ output.puts(output.gray('—' * 40)) if idx.positive?
89
+ render(profile)
90
+ end
91
+ end
92
+
93
+ def render(profile)
94
+ formatter = Formatters::ProfileFormatter.new(
95
+ output: output, emoji_replacer: runner.emoji_replacer
96
+ )
97
+ @options[:full] ? formatter.full(profile) : formatter.compact(profile)
98
+ end
99
+
100
+ def output_json_profiles(profiles)
101
+ payload = profiles.map { |p| profile_payload(p) }
102
+ output_json(profiles.size == 1 ? payload.first : payload)
103
+ 0
104
+ end
105
+
106
+ def profile_payload(profile)
107
+ profile.to_h.merge(
108
+ custom_fields: profile.visible_fields.map(&:to_h),
109
+ sections: profile.sections,
110
+ resolved_users: profile.resolved_users.transform_values(&:to_h)
111
+ )
112
+ end
113
+ end
114
+ end
115
+ end
@@ -18,6 +18,8 @@ module Slk
18
18
 
19
19
  attr_reader :verbose, :quiet
20
20
 
21
+ def color? = @color
22
+
21
23
  def initialize(io: $stdout, err: $stderr, color: nil, verbose: false, quiet: false)
22
24
  @io = io
23
25
  @err = err
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Formatters
5
+ # Renders ProfileField values to display strings (date with relative
6
+ # phrasing, link with alt text, type:user dereferenced via resolved_users).
7
+ class ProfileFieldRenderer
8
+ def initialize(output:)
9
+ @output = output
10
+ @hyperlinks = output.respond_to?(:color?) && output.color?
11
+ end
12
+
13
+ def render(field, profile)
14
+ case field.type
15
+ when 'date' then format_date(field.value)
16
+ when 'link' then format_link(field)
17
+ when 'user' then format_user_list(field, profile)
18
+ else format_text(field.value)
19
+ end
20
+ end
21
+
22
+ # Slack stores some text fields (notably free-text profile blocks) as
23
+ # rich_text JSON blocks. Detect and flatten to plain text.
24
+ def format_text(value)
25
+ text = value.to_s
26
+ return text unless text.start_with?('[{') && text.include?('rich_text')
27
+
28
+ flatten_rich_text(JSON.parse(text))
29
+ rescue JSON::ParserError
30
+ text
31
+ end
32
+
33
+ def flatten_rich_text(blocks)
34
+ Array(blocks).flat_map { |block| extract_text_from_block(block) }.join
35
+ end
36
+
37
+ def extract_text_from_block(block)
38
+ return [block.to_s] unless block.is_a?(Hash)
39
+
40
+ return [block['text'].to_s] if block['type'] == 'text'
41
+ return [block['name'] ? ":#{block['name']}:" : ''] if block['type'] == 'emoji'
42
+
43
+ Array(block['elements']).flat_map { |el| extract_text_from_block(el) }
44
+ end
45
+
46
+ def render_user_reference(user_id, profile)
47
+ ref = profile.resolved_users[user_id]
48
+ return user_id unless ref
49
+
50
+ suffix = ref.title.to_s.empty? ? '' : " — #{ref.title}"
51
+ pronouns = ref.pronouns.to_s.empty? ? '' : " #{@output.gray("(#{ref.pronouns})")}"
52
+ "#{ref.best_name}#{pronouns}#{suffix}"
53
+ end
54
+
55
+ private
56
+
57
+ def format_user_list(field, profile)
58
+ field.user_ids.map { |uid| render_user_reference(uid, profile) }.join(', ')
59
+ end
60
+
61
+ def format_date(value)
62
+ date = parse_date(value) or return value
63
+ relative = relative_phrase(date, Date.today)
64
+ formatted = date.strftime('%b %-d, %Y')
65
+ relative ? "#{formatted} (#{relative})" : formatted
66
+ end
67
+
68
+ def parse_date(value)
69
+ Date.parse(value.to_s)
70
+ rescue ArgumentError, TypeError
71
+ nil
72
+ end
73
+
74
+ def relative_phrase(then_date, now_date)
75
+ return nil if then_date >= now_date
76
+
77
+ years, months = years_months_between(then_date, now_date)
78
+ parts = []
79
+ parts << "#{years}y" if years.positive?
80
+ parts << "#{months}mo" if months.positive?
81
+ parts.empty? ? nil : "#{parts.join(' ')} ago"
82
+ end
83
+
84
+ def years_months_between(then_date, now_date)
85
+ months = ((now_date.year - then_date.year) * 12) + (now_date.month - then_date.month)
86
+ months -= 1 if now_date.day < then_date.day
87
+ [months / 12, months % 12]
88
+ end
89
+
90
+ def format_link(field)
91
+ url = field.value.to_s
92
+ label = field.alt.to_s.empty? ? url : field.alt
93
+ return url if label == url
94
+ return "#{label} (#{shorten_url(url)})" unless @hyperlinks
95
+
96
+ # OSC 8 hyperlink — clickable in iTerm2, Ghostty, Wezterm, Kitty, Vte.
97
+ "\e]8;;#{url}\a#{label}\e]8;;\a"
98
+ end
99
+
100
+ def shorten_url(url)
101
+ URI.parse(url).host || url
102
+ rescue URI::InvalidURIError
103
+ url
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Formatters
5
+ # Renders a Models::Profile for `slk who`.
6
+ # Compact mode: teems-style two-column card.
7
+ # Full mode: section-grouped layout matching Slack web UI.
8
+ class ProfileFormatter
9
+ MIN_LABEL_WIDTH = 8
10
+ MAX_LABEL_WIDTH = 20
11
+
12
+ def initialize(output:, emoji_replacer: nil)
13
+ @output = output
14
+ field_renderer = ProfileFieldRenderer.new(output: output)
15
+ @rows = ProfileRows.new(field_renderer: field_renderer, emoji_replacer: emoji_replacer)
16
+ end
17
+
18
+ def compact(profile)
19
+ render_header(profile)
20
+ emit_rows(@rows.compact(profile))
21
+ nil
22
+ end
23
+
24
+ def full(profile)
25
+ render_header(profile)
26
+ return emit_rows(@rows.external(profile)) if profile.external?
27
+
28
+ render_section('Contact information', @rows.contact(profile))
29
+ render_section('People', @rows.people(profile))
30
+ render_section('About me', @rows.about(profile))
31
+ end
32
+
33
+ def emit_rows(rows)
34
+ non_empty = rows.reject { |_, v| v.nil? || v.to_s.empty? }
35
+ width = label_width(non_empty)
36
+ non_empty.each { |label, value| emit_row(label, value, width) }
37
+ end
38
+
39
+ def label_width(rows)
40
+ max = rows.map { |label, _| label_for(label).length }.max || 0
41
+ max.clamp(MIN_LABEL_WIDTH, MAX_LABEL_WIDTH)
42
+ end
43
+
44
+ def label_for(label)
45
+ text = label.to_s
46
+ text.length > MAX_LABEL_WIDTH ? "#{text[0, MAX_LABEL_WIDTH - 1]}…" : text
47
+ end
48
+
49
+ private
50
+
51
+ def render_header(profile)
52
+ @output.puts(@output.bold(profile.best_name) + pronouns_suffix(profile))
53
+ header_tags(profile).each { |tag| @output.puts(" #{tag}") }
54
+ @output.puts
55
+ end
56
+
57
+ def header_tags(profile)
58
+ tags = []
59
+ tags << profile.title unless profile.title.to_s.empty?
60
+ tags << external_tag(profile) if profile.external?
61
+ tags << @output.bold('deactivated account') if profile.deleted
62
+ tags
63
+ end
64
+
65
+ def pronouns_suffix(profile)
66
+ profile.pronouns.to_s.empty? ? '' : " #{@output.gray("(#{profile.pronouns})")}"
67
+ end
68
+
69
+ def external_tag(profile)
70
+ @output.gray("external — #{profile.home_team_name || 'external workspace'}")
71
+ end
72
+
73
+ def render_section(title, rows)
74
+ return if rows.reject { |_, v| v.nil? || v.to_s.empty? }.empty?
75
+
76
+ @output.puts(@output.bold(title))
77
+ emit_rows(rows)
78
+ @output.puts
79
+ end
80
+
81
+ def emit_row(label, value, width)
82
+ padded = label_for(label).ljust(width)
83
+ @output.puts(" #{@output.gray(padded)} #{value}")
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Formatters
5
+ # Builds [label, value] row pairs from a Models::Profile.
6
+ # Pure data — no output, formatting handled by ProfileFormatter.
7
+ class ProfileRows
8
+ def initialize(field_renderer:, emoji_replacer: nil)
9
+ @fields = field_renderer
10
+ @emoji_replacer = emoji_replacer
11
+ end
12
+
13
+ def compact(profile)
14
+ contact(profile) + base(profile) + people(profile) + about(profile, skip_title: true)
15
+ end
16
+
17
+ def contact(profile)
18
+ [['Email', profile.email], ['Phone', profile.phone]]
19
+ end
20
+
21
+ def base(profile)
22
+ [
23
+ ['Presence', profile.presence_label],
24
+ ['Status', status_text(profile)],
25
+ ['Local', local_time(profile)]
26
+ ]
27
+ end
28
+
29
+ def people(profile)
30
+ profile.people_fields.flat_map do |field|
31
+ field.user_ids.map { |uid| [field.label, @fields.render_user_reference(uid, profile)] }
32
+ end
33
+ end
34
+
35
+ def about(profile, skip_title: false)
36
+ fields = profile.visible_fields.reject { |f| f.type == 'user' }
37
+ fields = fields.reject { |f| skip_title && duplicate_title?(f, profile) }
38
+ fields.map { |f| [f.label, @fields.render(f, profile)] }
39
+ end
40
+
41
+ def external(profile)
42
+ contact(profile) + [['Workspace', profile.home_team_name]]
43
+ end
44
+
45
+ private
46
+
47
+ def duplicate_title?(field, profile)
48
+ field.label.casecmp('Title').zero? && field.value.to_s == profile.title.to_s
49
+ end
50
+
51
+ def status_text(profile)
52
+ return nil if profile.status_text.empty? && profile.status_emoji.empty?
53
+
54
+ emoji = render_emoji(profile.status_emoji)
55
+ [emoji, profile.status_text].reject(&:empty?).join(' ')
56
+ end
57
+
58
+ def render_emoji(text)
59
+ return text if text.to_s.empty? || @emoji_replacer.nil?
60
+
61
+ @emoji_replacer.replace(text)
62
+ end
63
+
64
+ def local_time(profile)
65
+ return nil unless profile.tz
66
+
67
+ time_at_user = Time.now.utc + profile.tz_offset.to_i
68
+ "#{time_at_user.strftime('%-l:%M %p')} #{profile.tz_label}".strip
69
+ end
70
+ end
71
+ end
72
+ end